diff --git a/apps/web/app/account/history/page.tsx b/apps/web/app/account/history/page.tsx index 381cf6d..7f323c1 100644 --- a/apps/web/app/account/history/page.tsx +++ b/apps/web/app/account/history/page.tsx @@ -27,6 +27,11 @@ export default async function HistoryPage({ tag: sp.tag, mediaType: sp.media_type, }); + const exportParams = new URLSearchParams(); + if (result) exportParams.set("result", result); + if (sp.tag) exportParams.set("tag", sp.tag); + if (sp.media_type) exportParams.set("media_type", sp.media_type); + const exportHref = `/api/account/history/export${exportParams.size ? `?${exportParams.toString()}` : ""}`; return (
@@ -42,6 +47,7 @@ export default async function HistoryPage({ ); })} + Export CSV
diff --git a/apps/web/app/api/account/history/export/route.ts b/apps/web/app/api/account/history/export/route.ts new file mode 100644 index 0000000..4862d1c --- /dev/null +++ b/apps/web/app/api/account/history/export/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { + HISTORY_EXPORT_LIMIT, + historyExportFilename, + historyRowsToCsv, + parseHistoryExportFilters, +} from "@/lib/history-export"; +import { getUserHistory } from "@/lib/queries"; +import { getCurrentUser } from "@/lib/session"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Sign in first." }, { status: 401 }); + } + + const parsed = parseHistoryExportFilters(new URL(req.url).searchParams); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + + const rows = await getUserHistory(user.id, { + ...parsed.filters, + limit: HISTORY_EXPORT_LIMIT, + }); + return new NextResponse(historyRowsToCsv(rows), { + headers: { + "cache-control": "private, no-store", + "content-disposition": `attachment; filename="${historyExportFilename()}"`, + "content-type": "text/csv; charset=utf-8", + }, + }); +} diff --git a/apps/web/lib/history-export.test.ts b/apps/web/lib/history-export.test.ts new file mode 100644 index 0000000..10dcc51 --- /dev/null +++ b/apps/web/lib/history-export.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + HISTORY_EXPORT_LIMIT, + historyExportFilename, + historyRowsToCsv, + parseHistoryExportFilters, +} from "./history-export"; +import type { HistoryRow } from "./queries"; + +function row(overrides: Partial = {}): HistoryRow { + return { + mediaId: "media-1", + slug: "portrait-1", + title: "Portrait", + thumbnailUrl: null, + mediaUrl: "https://images.example.test/portrait.jpg", + mediaType: "image", + guess: "ai", + truthLabel: "not_ai", + isScored: true, + isCorrect: false, + createdAt: "2026-08-01 12:34:56", + ...overrides, + }; +} + +describe("history export filters", () => { + it("preserves supported result, tag, and media-type filters", () => { + const params = new URLSearchParams({ + result: "correct", + tag: " Portrait-Art ", + media_type: "image", + }); + expect(parseHistoryExportFilters(params)).toEqual({ + ok: true, + filters: { result: "correct", tag: "portrait-art", mediaType: "image" }, + }); + }); + + it("rejects unsupported filters instead of silently exporting another scope", () => { + expect(parseHistoryExportFilters(new URLSearchParams("result=all"))).toEqual({ + ok: false, + error: "Invalid result filter.", + }); + expect(parseHistoryExportFilters(new URLSearchParams("media_type=audio"))).toEqual({ + ok: false, + error: "Invalid media_type filter.", + }); + expect(parseHistoryExportFilters(new URLSearchParams("tag=../../users"))).toEqual({ + ok: false, + error: "Invalid tag filter.", + }); + }); + + it("keeps the export explicitly bounded", () => { + expect(HISTORY_EXPORT_LIMIT).toBe(5_000); + }); +}); + +describe("history CSV serialization", () => { + it("writes a UTF-8 BOM, RFC 4180 rows, and a final CRLF", () => { + const csv = historyRowsToCsv([ + row({ title: 'A "quoted",\nmultiline title' }), + ]); + + expect(csv.startsWith('\uFEFF"media_id","slug","title"')).toBe(true); + expect(csv).toContain('"A ""quoted"",\nmultiline title"'); + expect(csv.endsWith("\r\n")).toBe(true); + expect(csv.split("\r\n")).toHaveLength(3); + }); + + it("neutralizes spreadsheet formulas in untrusted text cells", () => { + const csv = historyRowsToCsv([ + row({ title: '=HYPERLINK("https://attacker.test")', slug: "+cmd" }), + ]); + + expect(csv).toContain('"\'=HYPERLINK(""https://attacker.test"")"'); + expect(csv).toContain('"\'+cmd"'); + }); + + it("exports correct, incorrect, and pending result labels", () => { + const csv = historyRowsToCsv([ + row({ mediaId: "correct", isCorrect: true }), + row({ mediaId: "incorrect", isCorrect: false }), + row({ mediaId: "pending", isScored: false, isCorrect: null }), + ]); + + expect(csv).toContain('"correct"'); + expect(csv).toContain('"incorrect"'); + expect(csv).toContain('"pending"'); + }); + + it("uses a fixed, filesystem-safe filename", () => { + expect(historyExportFilename(new Date("2026-08-01T23:59:59Z"))).toBe( + "aiornot-guess-history-2026-08-01.csv", + ); + }); +}); diff --git a/apps/web/lib/history-export.ts b/apps/web/lib/history-export.ts new file mode 100644 index 0000000..8fb859d --- /dev/null +++ b/apps/web/lib/history-export.ts @@ -0,0 +1,79 @@ +import type { HistoryRow } from "./queries"; + +export const HISTORY_EXPORT_LIMIT = 5_000; + +export type HistoryExportFilters = { + result?: "correct" | "incorrect" | "pending"; + tag?: string; + mediaType?: "image" | "video" | "link"; +}; + +export type HistoryExportFilterResult = + | { ok: true; filters: HistoryExportFilters } + | { ok: false; error: string }; + +const RESULTS = new Set(["correct", "incorrect", "pending"]); +const MEDIA_TYPES = new Set(["image", "video", "link"]); +const TAG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/; + +export function parseHistoryExportFilters(params: URLSearchParams): HistoryExportFilterResult { + const result = params.get("result")?.trim() || undefined; + const mediaType = params.get("media_type")?.trim() || undefined; + const tag = params.get("tag")?.trim().toLowerCase() || undefined; + + if (result && !RESULTS.has(result)) { + return { ok: false, error: "Invalid result filter." }; + } + if (mediaType && !MEDIA_TYPES.has(mediaType)) { + return { ok: false, error: "Invalid media_type filter." }; + } + if (tag && !TAG_RE.test(tag)) { + return { ok: false, error: "Invalid tag filter." }; + } + + return { + ok: true, + filters: { + result: result as HistoryExportFilters["result"], + tag, + mediaType: mediaType as HistoryExportFilters["mediaType"], + }, + }; +} + +function spreadsheetSafe(value: unknown): string { + const text = value == null ? "" : String(value); + return /^[\t\r\n ]*[=+\-@]/.test(text) ? `'${text}` : text; +} + +function csvCell(value: unknown): string { + return `"${spreadsheetSafe(value).replace(/"/g, '""')}"`; +} + +export function historyRowsToCsv(rows: HistoryRow[]): string { + const header = [ + "media_id", + "slug", + "title", + "media_type", + "your_guess", + "truth_label", + "result", + "guessed_at", + ]; + const body = rows.map((row) => [ + row.mediaId, + row.slug, + row.title, + row.mediaType, + row.guess, + row.truthLabel, + row.isScored ? (row.isCorrect ? "correct" : "incorrect") : "pending", + row.createdAt, + ]); + return `\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(",")).join("\r\n")}\r\n`; +} + +export function historyExportFilename(now = new Date()): string { + return `aiornot-guess-history-${now.toISOString().slice(0, 10)}.csv`; +}