Files
Artista-Filmcamp/src/lib/api.ts
T
Nick Szaunig 59bb5a38a3
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s
first commit
2026-09-27 06:06:56 +02:00

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) },
);
}