import { promises as fs } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { getServerConfig } from "@/lib/config"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** * Question reports. Write-only on purpose: the app has no auth, so nothing * here is readable over HTTP. Every report is appended as one JSON line to * `/reports.jsonl` under the shared data directory, where the * operator can inspect it: * * tail -f ~/freezmo-prod/shared/freezmo/reports/reports.jsonl * * The browser keeps its own copy of the reports — it is the source of truth * for skipping and Undo. This file is the cross-device record for tuning the * generation prompts. */ const reportSchema = z.object({ deckId: z.string().min(1).max(80), deckTitle: z.string().max(200).optional(), activityId: z.string().min(1).max(80), type: z.string().min(1).max(40), question: z.string().min(1).max(600), at: z.number(), }); /** A report is tiny; anything bigger is not one. */ const MAX_BODY_BYTES = 4096; export async function POST(request: Request) { const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); if (oversized) return oversized; const config = getServerConfig(); const limit = rateLimit(`reports:${clientKey(request)}`, config.rateLimits.reports, config.rateLimits.windowMs); if (!limit.allowed) return rateLimitResponse(limit, locale); const parsed = reportSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return Response.json({ error: "invalid report" }, { status: 400 }); const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() }); try { await fs.mkdir(config.reports.dir, { recursive: true }); const file = path.join(config.reports.dir, "reports.jsonl"); /* Disk guard: reports are a convenience for the operator, so once the file passes the cap the app stops appending (the browser's own list is still the source of truth for skipping). */ const size = await fs.stat(file).then((stats) => stats.size).catch(() => 0); if (size >= config.reports.maxBytes) { console.warn("[api/reports] reports.jsonl is at its size cap — not appending"); return Response.json({ ok: true }); } await fs.appendFile(file, `${line}\n`, "utf8"); } catch { return Response.json({ error: "could not store the report" }, { status: 500 }); } return Response.json({ ok: true }); }