Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/app/account/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="container" style={{ padding: "32px 24px" }}>
Expand All @@ -42,6 +47,7 @@ export default async function HistoryPage({
</Link>
);
})}
<a href={exportHref}>Export CSV</a>
</div>
</div>

Expand Down
36 changes: 36 additions & 0 deletions apps/web/app/api/account/history/export/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
98 changes: 98 additions & 0 deletions apps/web/lib/history-export.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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",
);
});
});
79 changes: 79 additions & 0 deletions apps/web/lib/history-export.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
Loading