diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index ddd41b3..3488e4f 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -25,6 +25,10 @@ app.use('*', async (c, next) => { origin: allowed, allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], + // Content-Disposition is not CORS-safelisted, so without this the admin UI + // cannot read the filename the CSV endpoints build, and every export saves + // under a name the client had to guess instead. + exposeHeaders: ['Content-Disposition'], maxAge: 86400, })(c, next); }); diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index 43a484d..7c4168a 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -1,4 +1,16 @@ -import { and, count, desc, eq, gte, inArray, lt, lte, or } from 'drizzle-orm'; +import { + and, + asc, + count, + desc, + eq, + gt, + gte, + inArray, + lt, + lte, + or, +} from 'drizzle-orm'; import { type Context, Hono } from 'hono'; import * as z from 'zod'; import type { HonoEnv } from '../auth'; @@ -44,8 +56,15 @@ const fallbackKey = z .string() .max(FALLBACK_KEY_MAX) .regex( - /^$|^[a-z0-9][a-z0-9-]*$/, - 'Use lowercase letters, digits and hyphens only', + // Underscore is allowed because social handles use it — the X account is + // x.com/nut_fes — and it costs nothing to carry: `_` is unreserved in RFC + // 3986, so encodeURIComponent leaves it alone, and the QR payload is + // already in byte mode (lowercase letters are absent from QR's + // alphanumeric set), so it is the same 8 bits as any other character. + // Hyphen stays last in the class: `[a-z0-9-_]` would read `9-_` as a range + // and quietly admit uppercase and punctuation. + /^$|^[a-z0-9][a-z0-9_-]*$/, + 'Use lowercase letters, digits, hyphens and underscores only', ); const createProjectBodySchema = z.object({ @@ -132,6 +151,40 @@ function denyUnlessPermitted( const projectsApp = new Hono(); +/** + * Scan counts for the QR codes on one page, as `{ [qrId]: count }`. + * + * Scoped to the ids being rendered, never the whole table — the same rule the + * project list follows. It costs one query backed by idx_access_logs_qr_id, and + * the rows it scans are only those belonging to the visible codes. + * + * ## Why this is counted rather than stored + * + * A `QRCodes.scan_count` column incremented on every redirect would make this + * free to display, but it would add a second D1 write per scan. On the Free plan + * writes are the binding limit (100k/day) while reads are not (5M/day) — so + * caching the count in a column would halve the number of scans the event can + * record in order to save a resource there is 50x more of. Wrong direction. + * + * Cost, for the record: reads scale with (logs belonging to the visible codes) x + * (page views). At 20k logs all belonging to one page that is 20k rows per view, + * i.e. ~250 views/day against the quota — fine for an admin screen, and the + * reason this is not wired to a poll or a keystroke. + */ +async function scanCountsByQrId( + db: ReturnType, + qrIds: string[], +): Promise> { + if (qrIds.length === 0) return {}; + const rows = await db + .select({ qrId: schema.accessLogs.qrId, scanCount: count() }) + .from(schema.accessLogs) + .where(inArray(schema.accessLogs.qrId, qrIds)) + .groupBy(schema.accessLogs.qrId) + .all(); + return Object.fromEntries(rows.map((row) => [row.qrId, row.scanCount])); +} + // GET /projects/qrcodes — paginated QR codes across every project projectsApp.get('/qrcodes', async (c) => { const denied = denyUnlessPermitted( @@ -153,7 +206,17 @@ projectsApp.get('/qrcodes', async (c) => { .all(), db.select({ total: count() }).from(schema.qrCodes), ]); - return c.json({ data: qrCodes, total: totalRows[0]?.total ?? 0 }); + const scanCounts = await scanCountsByQrId( + db, + qrCodes.map((qr) => qr.id), + ); + return c.json({ + data: qrCodes.map((qr) => ({ + ...qr, + scanCount: scanCounts[qr.id] ?? 0, + })), + total: totalRows[0]?.total ?? 0, + }); }); // GET /projects/qrcodes/:id — a single QR code @@ -451,7 +514,17 @@ projectsApp.get('/:id/qrcodes', async (c) => { .from(schema.qrCodes) .where(eq(schema.qrCodes.projectId, projectId)), ]); - return c.json({ data: qrCodes, total: totalRows[0]?.total ?? 0 }); + const scanCounts = await scanCountsByQrId( + db, + qrCodes.map((qr) => qr.id), + ); + return c.json({ + data: qrCodes.map((qr) => ({ + ...qr, + scanCount: scanCounts[qr.id] ?? 0, + })), + total: totalRows[0]?.total ?? 0, + }); }); // GET /projects/:id/access-logs — paginated, newest-first raw access log @@ -536,6 +609,247 @@ function normalizeBound(value: string, edge: 'start' | 'end'): string { return edge === 'start' ? `${value}T00:00:00.000Z` : `${value}T23:59:59.999Z`; } +/** + * Most projects one request may combine. + * + * Bounds the `IN (...)` list, but the limit that matters is CSV_MAX_ROWS below, + * which is applied to the *combined* total rather than per project — otherwise + * ticking ten boxes would authorise ten times the row budget in a single click, + * and on the Free plan that is 10% of the daily read quota gone at once. + */ +const MAX_CSV_PROJECTS = 50; + +const bulkCsvQuerySchema = csvQuerySchema.extend({ + // Comma-separated so the whole thing stays a GET: the browser has to navigate + // to it (or fetch and save a blob) and a POST cannot be a plain download. + projectIds: z + .string() + .min(1) + .max(MAX_CSV_PROJECTS * 40), +}); + +// GET /projects/access-logs/csv?projectIds=a,b,c — one CSV covering several +// projects, for the checkbox selection in the admin UI. +// +// Two path segments, so it cannot be swallowed by `/:id` (one segment) or by +// `/qrcodes/:id` (first segment is literal). +projectsApp.get('/access-logs/csv', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_ANALYTICS, + 'TRACKING_LINK_ANALYTICS', + ); + if (denied) return denied; + + if (!isFlagEnabled(c.env.CSV_EXPORT_ENABLED)) { + return fail(c, 403, ErrorCodes.CSV_EXPORT_DISABLED); + } + + const parsedQuery = bulkCsvQuerySchema.safeParse(c.req.query()); + if (!parsedQuery.success) { + return fail(c, 400, ErrorCodes.INVALID_BODY, { + details: parsedQuery.error.flatten(), + }); + } + + const projectIds = [ + ...new Set( + parsedQuery.data.projectIds + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + ), + ]; + if (projectIds.length === 0) { + return fail(c, 400, ErrorCodes.INVALID_BODY, { + details: { formErrors: ['projectIds is empty'], fieldErrors: {} }, + }); + } + if (projectIds.length > MAX_CSV_PROJECTS) { + return fail(c, 400, ErrorCodes.INVALID_BODY, { + meta: { count: projectIds.length, max: MAX_CSV_PROJECTS }, + }); + } + + const from = parsedQuery.data.from + ? normalizeBound(parsedQuery.data.from, 'start') + : undefined; + const to = parsedQuery.data.to + ? normalizeBound(parsedQuery.data.to, 'end') + : undefined; + + const db = getDb(c.env.DB); + const rangeFilter = and( + inArray(schema.accessLogs.projectId, projectIds), + ...(from ? [gte(schema.accessLogs.accessedAt, from)] : []), + ...(to ? [lte(schema.accessLogs.accessedAt, to)] : []), + ); + + const [projects, countRows] = await Promise.all([ + db + .select({ + projectId: schema.projects.projectId, + name: schema.projects.name, + }) + .from(schema.projects) + .where(inArray(schema.projects.projectId, projectIds)) + .all(), + db.select({ total: count() }).from(schema.accessLogs).where(rangeFilter), + ]); + // Every id has to exist. Silently skipping unknown ones would produce a file + // that looks complete but is missing a project the user ticked. + if (projects.length !== projectIds.length) { + return fail(c, 404, ErrorCodes.PROJECT_NOT_FOUND, { + meta: { + requested: projectIds.length, + found: projects.length, + }, + }); + } + const projectNames = Object.fromEntries( + projects.map((project) => [project.projectId, project.name]), + ); + + const maxRows = + Number(c.env.CSV_MAX_ROWS ?? DEFAULT_MAX_CSV_ROWS) || DEFAULT_MAX_CSV_ROWS; + const total = countRows[0]?.total ?? 0; + if (total > maxRows) { + return fail(c, 413, ErrorCodes.TOO_MANY_ROWS, { + meta: { total, max: maxRows }, + }); + } + + // プロジェクト leads, because in a combined file it is the column that says + // which project a row belongs to — the single-project export has no need of it. + const header = [ + 'プロジェクト', + '日時', + '名前', + '媒体', + '場所', + 'ボット', + 'User Agent', + 'IPアドレス', + ]; + const encoder = new TextEncoder(); + // Ordered by project first, then time descending within each project. Not a + // global chronological sort: the only index available is + // (project_id, accessed_at DESC), and ordering across projects by time alone + // would force SQLite to sort the whole matched set in memory — 50k rows of that + // inside a Worker is how you hit the 10ms CPU limit. Grouping by project also + // happens to be the more useful layout in a spreadsheet. + let cursor: { projectId: string; accessedAt: string; id: number } | null = + null; + let done = false; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`${toCsvRow(header)}\r\n`)); + }, + async pull(controller) { + if (done) { + controller.close(); + return; + } + const rows = await db + .select({ + id: schema.accessLogs.id, + projectId: schema.accessLogs.projectId, + accessedAt: schema.accessLogs.accessedAt, + isBot: schema.accessLogs.isBot, + userAgent: schema.accessLogs.userAgent, + ipAddress: schema.accessLogs.ipAddress, + name: schema.qrCodes.name, + medium: schema.qrCodes.medium, + location: schema.qrCodes.location, + }) + .from(schema.accessLogs) + .leftJoin(schema.qrCodes, eq(schema.accessLogs.qrId, schema.qrCodes.id)) + .where( + cursor + ? and( + rangeFilter, + // Three-column keyset matching the ORDER BY exactly: advance to + // a later project, or stay in this one and move back in time. + or( + gt(schema.accessLogs.projectId, cursor.projectId), + and( + eq(schema.accessLogs.projectId, cursor.projectId), + or( + lt(schema.accessLogs.accessedAt, cursor.accessedAt), + and( + eq(schema.accessLogs.accessedAt, cursor.accessedAt), + lt(schema.accessLogs.id, cursor.id), + ), + ), + ), + ), + ) + : rangeFilter, + ) + .orderBy( + asc(schema.accessLogs.projectId), + desc(schema.accessLogs.accessedAt), + desc(schema.accessLogs.id), + ) + .limit(CSV_PAGE_SIZE) + .all(); + + if (rows.length === 0) { + controller.close(); + return; + } + + controller.enqueue( + encoder.encode( + `${rows + .map((log) => + toCsvRow([ + projectNames[log.projectId] ?? '', + log.accessedAt, + log.name ?? '', + log.medium ?? '', + log.location ?? '', + log.isBot ? '1' : '0', + log.userAgent ?? '', + log.ipAddress ?? '', + ]), + ) + .join('\r\n')}\r\n`, + ), + ); + + const last = rows[rows.length - 1]; + cursor = { + projectId: last.projectId, + accessedAt: last.accessedAt, + id: last.id, + }; + if (rows.length < CSV_PAGE_SIZE) done = true; + }, + }); + + return new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': contentDisposition( + // Requested order, not the order the IN(...) happened to return: the + // filename names the first project and counts the rest, and SQLite + // answers in rowid order, so without this the file gets named after + // whichever selected project is oldest rather than the one at the top + // of the user's selection. + bulkCsvFilename( + projectIds.map((id) => projectNames[id] ?? ''), + from, + to, + ), + ), + 'Cache-Control': 'no-store', + }, + }); +}); + // GET /projects/:id/access-logs/csv — access log as a streamed CSV download. // Gated by CSV_EXPORT_ENABLED so it can ship disabled and be turned on later. projectsApp.get('/:id/access-logs/csv', async (c) => { @@ -695,6 +1009,27 @@ projectsApp.get('/:id/access-logs/csv', async (c) => { }); }); +/** + * `アクセスログ_造形大祭2026ほか3件_2026-07-25.csv` for a multi-project export. + * + * Names the first project and counts the rest rather than joining them all: five + * Japanese project names concatenated overruns what a downloads folder will show, + * and the file already carries a プロジェクト column for the detail. A selection of + * one is named exactly as the single-project export would name it, so ticking one + * box and using the per-row button give the same file. + */ +function bulkCsvFilename( + projectNames: string[], + from?: string, + to?: string, +): string { + const [first, ...rest] = projectNames; + const label = rest.length + ? `${first ?? ''}ほか${rest.length}件` + : (first ?? 'project'); + return csvFilename(label, from, to); +} + /** `アクセスログ_造形大祭2026_2026-07-25.csv`, sanitised for a filesystem. */ function csvFilename(projectName: string, from?: string, to?: string): string { const safeName = diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index 982de01..1b04929 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -61,7 +61,16 @@ // outage rather than during one. "FALLBACK_DESTINATIONS": { "instagram": "https://www.instagram.com/nutfes?igsh=MTB1eXhpcWJ4YnQ0cA==", - "web": "https://www.nutfes.net/" + "web": "https://www.nutfes.net/", + // X is listed under two keys on purpose. "x" is what the admin UI derives + // from the host x.com, so it is the keyword a project gets if the field is + // left blank; "nut_fes" is the handle, which is what someone naming the + // keyword by hand reaches for. An unmatched keyword would silently send + // scans to FALLBACK_URL instead — exactly the case this map exists to + // prevent — and a spare entry costs nothing. Drop one once the projects + // are set up and you know which is in use. + "x": "https://x.com/nut_fes", + "nut_fes": "https://x.com/nut_fes" } } // Secrets (set with `wrangler secret put `, never committed): diff --git a/packages/web/src/hooks/useFieldErrors.test.ts b/packages/web/src/hooks/useFieldErrors.test.ts new file mode 100644 index 0000000..fdcda96 --- /dev/null +++ b/packages/web/src/hooks/useFieldErrors.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { validateFallbackKey, validateHttpUrl } from './useFieldErrors'; + +/** + * The fallback keyword is printed into QR codes, so the set of characters + * accepted here has to match the API's exactly — a key the form accepts but the + * API rejects is a save that fails after the user has already decided. + */ +describe('validateFallbackKey', () => { + const ok = (value: string) => expect(validateFallbackKey(value)).toBeNull(); + const rejected = (value: string) => + expect(validateFallbackKey(value)).toBe('validation.fallbackKey'); + + it('accepts underscores, which social handles need', () => { + // The X account is x.com/nut_fes. + ok('nut_fes'); + ok('a_b_c'); + }); + + it('accepts the existing keywords', () => { + ok('instagram'); + ok('web'); + ok('x'); + ok('my-shop'); + ok('a1'); + }); + + // The character class must be written [a-z0-9_-] with the hyphen last. + // [a-z0-9-_] parses `9-_` as a range spanning ':' through '_', which would + // wave through uppercase and punctuation. + it('still rejects everything outside the class', () => { + for (const value of [ + 'A', + 'Insta', + 'a b', + 'a.b', + 'a:b', + 'a@b', + 'a^b', + '@', + ]) { + rejected(value); + } + }); + + it('requires an alphanumeric first character', () => { + rejected('_leading'); + rejected('-leading'); + // Trailing is fine — only the first character is constrained. + ok('trailing_'); + ok('trailing-'); + }); + + it('rejects an empty value', () => { + // The field is optional, but blank is handled by not validating at all; + // reaching here with '' means a required-field rule was expected. + rejected(''); + }); + + it('rejects non-ASCII, which would inflate the printed QR', () => { + rejected('インスタ'); + }); +}); + +describe('validateHttpUrl', () => { + it('accepts http and https', () => { + expect(validateHttpUrl('https://x.com/nut_fes')).toBeNull(); + expect(validateHttpUrl('http://example.com')).toBeNull(); + }); + + // zod's .url() accepts these, which is how they became a stored-XSS route. + it('rejects other schemes', () => { + for (const value of [ + 'javascript:alert(1)', + 'data:text/html,