This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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) },
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user