/** * 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 }; const MAX_BODY_BYTES = 16 * 1024; export function ok(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 = {}): 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( request: Request, schema: T, ): Promise<{ ok: true; data: z.infer } | { 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 = {}; 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 { 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) }, ); }