import { promises as fs } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth } from "@/lib/config"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { translate } from "@/lib/i18n-dict"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** * Opt-in encrypted sync storage. The server only ever sees: * - a random sync ID (the capability) * - an opaque AES-GCM envelope (salt, iv, ciphertext) and a server timestamp * It cannot read the data and never receives the passphrase or API key. */ const idSchema = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/); const envelopeSchema = z.object({ v: z.literal(1), kdf: z.literal("PBKDF2-SHA256"), iterations: z.number().int().min(50_000).max(2_000_000), salt: z.string().min(8).max(128), iv: z.string().min(8).max(64), ciphertext: z.string().min(1).max(24_000_000), }); const postSchema = z.object({ id: idSchema, envelope: envelopeSchema }); function fileFor(dir: string, id: string): string { return path.join(dir, `${id}.json`); } async function writeAtomic(file: string, data: string): Promise { await fs.mkdir(path.dirname(file), { recursive: true }); const temporary = `${file}.${crypto.randomUUID()}.tmp`; await fs.writeFile(temporary, data, "utf8"); await fs.rename(temporary, file); } /** Size of all stored blobs except `exclude` (the one being replaced). */ async function directoryBytes(dir: string, exclude: string): Promise { let total = 0; try { for (const entry of await fs.readdir(dir)) { if (!entry.endsWith(".json")) continue; const full = path.join(dir, entry); if (full === exclude) continue; try { total += (await fs.stat(full)).size; } catch { /* deleted meanwhile */ } } } catch { /* directory doesn't exist yet */ } return total; } export async function GET(request: Request) { const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const config = getServerConfig(); const limit = rateLimit(`sync:${clientKey(request)}`, config.rateLimits.sync, config.rateLimits.windowMs); if (!limit.allowed) return rateLimitResponse(limit, locale); const auth = resolveAuth(config, request); if ("error" in auth) { return Response.json( { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, { status: 401 }, ); } const id = new URL(request.url).searchParams.get("id") ?? ""; if (!idSchema.safeParse(id).success) { return Response.json( { error: translate(locale, "server.syncInvalidId") }, { status: 400, headers: { "Cache-Control": "no-store" } }, ); } try { const raw = await fs.readFile(fileFor(config.sync.dir, id), "utf8"); return new Response(raw, { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, }); } catch { return Response.json( { error: translate(locale, "server.syncNotFound") }, { status: 404, headers: { "Cache-Control": "no-store" } }, ); } } export async function POST(request: Request) { const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const config = getServerConfig(); const oversized = rejectOversizedBody(request, config.sync.maxBytes); if (oversized) return oversized; const limit = rateLimit(`sync:${clientKey(request)}`, config.rateLimits.sync, config.rateLimits.windowMs); if (!limit.allowed) return rateLimitResponse(limit, locale); // Auth before parsing the body, like every other route. const auth = resolveAuth(config, request); if ("error" in auth) { return Response.json( { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, { status: 401 }, ); } let payload: unknown; try { payload = await request.json(); } catch { return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); } const parsed = postSchema.safeParse(payload); if (!parsed.success) { return Response.json( { error: translate(locale, "server.invalidRequest", { detail: parsed.error.issues .slice(0, 3) .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) .join("; "), }), }, { status: 400 }, ); } const file = fileFor(config.sync.dir, parsed.data.id); const serialised = JSON.stringify({ updatedAt: Date.now(), envelope: parsed.data.envelope }); // Total storage cap, checked only when a new blob is created. const exists = await fs .stat(file) .then(() => true) .catch(() => false); if (!exists) { const used = await directoryBytes(config.sync.dir, file); if (used + serialised.length > config.sync.totalBytes) { return Response.json( { error: translate(locale, "server.syncStorageFull"), kind: "storage_full" }, { status: 507 }, ); } } await writeAtomic(file, serialised); const updatedAt = (JSON.parse(serialised) as { updatedAt: number }).updatedAt; return Response.json({ updatedAt }, { headers: { "Cache-Control": "no-store" } }); } export async function DELETE(request: Request) { const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const config = getServerConfig(); const limit = rateLimit(`sync:${clientKey(request)}`, config.rateLimits.sync, config.rateLimits.windowMs); if (!limit.allowed) return rateLimitResponse(limit, locale); const auth = resolveAuth(config, request); if ("error" in auth) { return Response.json( { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, { status: 401 }, ); } const id = new URL(request.url).searchParams.get("id") ?? ""; if (!idSchema.safeParse(id).success) { return Response.json( { error: translate(locale, "server.syncInvalidId") }, { status: 400, headers: { "Cache-Control": "no-store" } }, ); } await fs.unlink(fileFor(config.sync.dir, id)).catch(() => {}); return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }); }