Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s
82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
/**
|
|
* Gemeinsame Bausteine fuer die oeffentlichen API-Routen: einheitliche
|
|
* Antwortform, Schutz schreibender Endpunkte, Rate Limiting.
|
|
*/
|
|
import "./zod-de";
|
|
import { NextResponse } from "next/server";
|
|
import type { z } from "zod";
|
|
import { getDb } from "./db";
|
|
import { env, requireAppSecret } from "./env";
|
|
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
|
|
|
|
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
|
|
|
const MAX_BODY_BYTES = 16 * 1024;
|
|
|
|
export function ok<T>(data: T, status = 200): NextResponse {
|
|
return NextResponse.json({ success: true, data }, { status, headers: { "Cache-Control": "no-store" } });
|
|
}
|
|
|
|
export function fail(status: number, error: ApiError, headers: Record<string, string> = {}): NextResponse {
|
|
return NextResponse.json({ success: false, error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
|
}
|
|
|
|
/**
|
|
* CSRF-Schutz: schreibende Aufrufe nur von der eigenen Seite (Origin bzw.
|
|
* Sec-Fetch-Site) und nur mit JSON — ein fremdes HTML-Formular kann weder
|
|
* das eine noch das andere liefern.
|
|
*/
|
|
export function checkSameOrigin(request: Request): NextResponse | null {
|
|
const contentType = request.headers.get("content-type") ?? "";
|
|
if (!contentType.startsWith("application/json")) {
|
|
return fail(415, { code: "UNSUPPORTED_MEDIA_TYPE", message: "Anfrage muss JSON sein." });
|
|
}
|
|
const site = request.headers.get("sec-fetch-site");
|
|
const origin = request.headers.get("origin");
|
|
const forbidden = fail(403, { code: "FORBIDDEN", message: "Anfrage nicht erlaubt." });
|
|
// Fail closed: ohne jede Herkunftsangabe keine Annahme.
|
|
if (!site && !origin) return forbidden;
|
|
if (site && site !== "same-origin") return forbidden;
|
|
if (origin && origin !== new URL(env().APP_URL).origin) return forbidden;
|
|
return null;
|
|
}
|
|
|
|
export async function readJson<T extends z.ZodType>(
|
|
request: Request,
|
|
schema: T,
|
|
): Promise<{ ok: true; data: z.infer<T> } | { ok: false; response: NextResponse }> {
|
|
const raw = await request.text();
|
|
if (raw.length > MAX_BODY_BYTES) {
|
|
return { ok: false, response: fail(413, { code: "TOO_LARGE", message: "Anfrage ist zu groß." }) };
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return { ok: false, response: fail(400, { code: "INVALID_JSON", message: "Anfrage konnte nicht gelesen werden." }) };
|
|
}
|
|
const result = schema.safeParse(parsed);
|
|
if (!result.success) {
|
|
const fields: Record<string, string> = {};
|
|
for (const issue of result.error.issues) {
|
|
const key = issue.path.join(".");
|
|
if (!fields[key]) fields[key] = issue.message;
|
|
}
|
|
return {
|
|
ok: false,
|
|
response: fail(422, { code: "VALIDATION", message: "Bitte prüfe die markierten Angaben.", fields }),
|
|
};
|
|
}
|
|
return { ok: true, data: result.data };
|
|
}
|
|
|
|
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
|
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
|
|
if (result.allowed) return null;
|
|
return fail(
|
|
429,
|
|
{ code: "RATE_LIMITED", message: "Zu viele Anfragen. Bitte warte einen Moment und versuche es dann erneut." },
|
|
{ "Retry-After": String(result.retryAfterSeconds) },
|
|
);
|
|
}
|