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`;
+}