import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; export const SESSION_COOKIE = 'rc_session'; export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days export const MAX_RECIPE_BYTES = 256 * 1024; // A phone's 12MP JPEG lands around 4-8MB, so 3MB rejected real photos with a // 413. The client downscales to 2048px before uploading (see shrinkForUpload), // which keeps normal uploads well under this; the cap stays generous for a // full-size PNG or a photo that arrived from elsewhere. Under nginx's // `client_max_body_size 16m`, so an over-limit upload is still rejected with // our JSON error instead of nginx's HTML 413. export const MAX_PHOTO_BYTES = 12 * 1024 * 1024; export const MAX_PHOTOS_PER_USER = 12; const DATA_DIR = process.env.DATA_DIR || './data'; mkdirSync(DATA_DIR, { recursive: true }); // Uploaded originals. Filenames are server-generated hex — a user filename // never reaches the filesystem, so there is no traversal or collision surface. const UPLOAD_DIR = join(DATA_DIR, 'uploads'); mkdirSync(UPLOAD_DIR, { recursive: true }); export const photoPath = (file: string) => join(UPLOAD_DIR, file); // Profile pictures, one per account, named the same way. const AVATAR_DIR = join(DATA_DIR, 'avatars'); mkdirSync(AVATAR_DIR, { recursive: true }); export const avatarPath = (file: string) => join(AVATAR_DIR, file); export const db = new Database(join(DATA_DIR, 'recipescam.db')); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS recipes ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, name TEXT NOT NULL, json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS photos ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, file TEXT NOT NULL, mime TEXT NOT NULL, bytes INTEGER NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY, at TEXT NOT NULL, kind TEXT NOT NULL, path TEXT NOT NULL, target TEXT, visitor TEXT NOT NULL, user_id INTEGER, country TEXT, region TEXT, city TEXT, browser TEXT, os TEXT, device TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id); CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id); CREATE INDEX IF NOT EXISTS idx_events_at ON events(at); `); // Where a curated photo is allowed to appear on the landing page: the community // strip, the live tester's preview, the creator lab's preview, or the QR card. // One is picked at random out of its slot on every page load. export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const; export type PhotoSlot = (typeof PHOTO_SLOTS)[number]; export const isPhotoSlot = (v: unknown): v is PhotoSlot => typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v); // The column arrived after the first strips were already on disk, so add it in // place — `CREATE TABLE IF NOT EXISTS` would silently skip an existing table. { const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; if (!cols.some((c) => c.name === 'slot')) { db.exec(`ALTER TABLE photos ADD COLUMN slot TEXT NOT NULL DEFAULT 'strip'`); } } // The avatar column arrived after the first accounts did, same as photos.slot. { const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; if (!cols.some((c) => c.name === 'avatar')) { db.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`); } } // Moderation state, added after the first accounts existed: // blocked — may not sign in (or stay signed in); the row is kept whole. // deleted_at — "removed" from the site: hidden from the strip, cannot sign // in, but restorable. A hard DELETE is the separate, final act. { const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; if (!cols.some((c) => c.name === 'blocked')) { db.exec(`ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`); } if (!cols.some((c) => c.name === 'deleted_at')) { db.exec(`ALTER TABLE users ADD COLUMN deleted_at TEXT`); } } // The strip's own labels, added after the first contributions were on disk: the // tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title // and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are // optional and length-capped by the route that accepts them. { const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; for (const name of ['tag', 'title', 'meta']) { if (!cols.some((c) => c.name === name)) { db.exec(`ALTER TABLE photos ADD COLUMN ${name} TEXT`); } } } // The saved-photo flow, added later still: // recipe — the look that made these pixels (same JSON a recipe row holds), so // the studio can open the photo again and keep editing it. // consent — the uploader's own say over the landing strip. The params live in // this row, never burned into the image. { const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; if (!cols.some((c) => c.name === 'consent')) { db.exec(`ALTER TABLE photos ADD COLUMN consent INTEGER NOT NULL DEFAULT 1`); } if (!cols.some((c) => c.name === 'recipe')) { db.exec(`ALTER TABLE photos ADD COLUMN recipe TEXT`); } } // The photo's own edit history: the looks it carried before the last few saves, // newest first, capped at PHOTO_HISTORY_MAX. Re-saving an open photo replaces // its pixels and its recipe, so the previous recipe is the only way back — a // short JSON array is enough of a log for that. export const PHOTO_HISTORY_MAX = 3; { const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; if (!cols.some((c) => c.name === 'history')) { db.exec(`ALTER TABLE photos ADD COLUMN history TEXT`); } } // `avatar` is the stored file name, or null for "no picture". export type User = { id: number; email: string; avatar: string | null; blocked: number; deletedAt: string | null; }; export type Recipe = { id: number; name: string; recipe: unknown; createdAt: string; updatedAt: string; }; // scrypt: per-user random salt, stored as "salt:hash" (hex). const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 } as const; export function hashPassword(password: string): string { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(password, salt, SCRYPT.keylen, SCRYPT).toString('hex'); return `${salt}:${hash}`; } export function verifyPassword(password: string, stored: string): boolean { const [salt, hash] = stored.split(':'); if (!salt || !hash) return false; const expected = Buffer.from(hash, 'hex'); const actual = scryptSync(password, salt, SCRYPT.keylen, SCRYPT); return expected.length === actual.length && timingSafeEqual(expected, actual); } // Burned on unknown-email logins so response time does not leak account existence. export const DUMMY_HASH = hashPassword('invalid-password-placeholder'); const now = () => new Date().toISOString(); export function createUser(email: string, password: string): User | null { try { const info = db .prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)') .run(email, hashPassword(password), now()); return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null }; } catch (err) { if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null; throw err; } } export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined { return db .prepare( 'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, password_hash FROM users WHERE email = ?', ) .get(email) as (User & { password_hash: string }) | undefined; } export function findUserById(id: number): User | undefined { return db .prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt FROM users WHERE id = ?') .get(id) as User | undefined; } // Swaps the picture and hands back the file it replaced, so the caller can // unlink it — the row is the only index of what is on disk. export function setUserAvatar(id: number, file: string): string | null { const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined; if (!row) return null; db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(file, id); return row.avatar; } // Avatars are public by nature — they sit next to a name — so this is not // session-gated. It returns only the row's own file name, never a client path. export function userAvatar(id: number): string | undefined { const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined; return row?.avatar ?? undefined; } export function createSession(userId: number): string { const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_S * 1000).toISOString(); db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( token, userId, expiresAt, ); return token; } export function sessionUser(token: string): User | undefined { const row = db .prepare('SELECT token, user_id AS userId, expires_at AS expiresAt FROM sessions WHERE token = ?') .get(token) as { token: string; userId: number; expiresAt: string } | undefined; if (!row) return undefined; if (row.expiresAt <= now()) { db.prepare('DELETE FROM sessions WHERE token = ?').run(row.token); // lazy cleanup return undefined; } const user = findUserById(row.userId); // Belt to the braces of the session sweep in setUserBlocked/setUserRemoved. if (!user || user.blocked || user.deletedAt) return undefined; return user; } export function deleteSession(token: string): void { db.prepare('DELETE FROM sessions WHERE token = ?').run(token); } export function listRecipes(userId: number): Recipe[] { const rows = db .prepare( 'SELECT id, name, json, created_at AS createdAt, updated_at AS updatedAt FROM recipes WHERE user_id = ? ORDER BY updated_at DESC, id DESC', ) .all(userId) as { id: number; name: string; json: string; createdAt: string; updatedAt: string }[]; return rows.map((r) => ({ id: r.id, name: r.name, recipe: JSON.parse(r.json), createdAt: r.createdAt, updatedAt: r.updatedAt })); } export function getRecipe(userId: number, id: number): Recipe | undefined { const row = db .prepare( 'SELECT id, name, json, created_at AS createdAt, updated_at AS updatedAt FROM recipes WHERE id = ? AND user_id = ?', ) .get(id, userId) as { id: number; name: string; json: string; createdAt: string; updatedAt: string } | undefined; return row && { id: row.id, name: row.name, recipe: JSON.parse(row.json), createdAt: row.createdAt, updatedAt: row.updatedAt }; } export function createRecipe(userId: number, name: string, recipe: unknown): Recipe { const ts = now(); const info = db .prepare('INSERT INTO recipes (user_id, name, json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)') .run(userId, name, JSON.stringify(recipe), ts, ts); const id = Number(info.lastInsertRowid); return { id, name, recipe, createdAt: ts, updatedAt: ts }; } export function updateRecipe(userId: number, id: number, name: string, recipe: unknown): Recipe | undefined { const ts = now(); const info = db .prepare('UPDATE recipes SET name = ?, json = ?, updated_at = ? WHERE id = ? AND user_id = ?') .run(name, JSON.stringify(recipe), ts, id, userId); if (info.changes === 0) return undefined; return getRecipe(userId, id); } export function deleteRecipe(userId: number, id: number): boolean { return db.prepare('DELETE FROM recipes WHERE id = ? AND user_id = ?').run(id, userId).changes > 0; } // ---- contributed strip photos ------------------------------------------- // The public shape carries no owner: the landing page is anonymous, so the // uploader's email must never be reachable from an unauthenticated request. // `tag`/`title`/`meta` are the frame's own labels (see the migration above). export type Photo = { id: number; createdAt: string; slot: PhotoSlot; tag: string | null; title: string | null; meta: string | null; // The uploader's permission for this photo to appear on the landing strip. // The owner's own folder reads it back to draw the toggle. consent: boolean; }; // The owner's own row adds the look that made it, so it can be opened again, // and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX. export type MyPhoto = Photo & { recipe: unknown | null; history: unknown[] }; export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number }; export type PhotoMeta = { tag?: string | null; title?: string | null; meta?: string | null; recipe?: unknown; consent?: boolean; }; // One SELECT list, so the call sites cannot drift apart. const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot, photos.tag AS tag, photos.title AS title, photos.meta AS meta, photos.consent AS consent`; // SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text. type PhotoRow = Omit & { consent: number }; type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null }; const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 }); // A row whose JSON will not parse is still a photo: its settings are simply // gone, not worth failing the whole folder over. Same for one bad entry in the // history — the rest of the list still stands. const parseJson = (raw: string | null): unknown => { try { return raw ? JSON.parse(raw) : null; } catch { return null; } }; const toMyPhoto = (row: MyPhotoRow): MyPhoto => { const history = parseJson(row.history); return { ...toPhoto(row), recipe: parseJson(row.recipe), history: Array.isArray(history) ? history : [], }; }; export function listPhotos(): Photo[] { return ( db .prepare( `SELECT ${PHOTO_COLUMNS} FROM photos JOIN users ON users.id = photos.user_id WHERE users.deleted_at IS NULL ORDER BY photos.id DESC`, ) .all() as PhotoRow[] ).map(toPhoto); } export function listPhotosWithOwner(): AdminPhoto[] { return ( db .prepare( `SELECT ${PHOTO_COLUMNS}, photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes, users.email AS email FROM photos JOIN users ON users.id = photos.user_id ORDER BY photos.id DESC`, ) .all() as (PhotoRow & { userId: number; email: string; mime: string; bytes: number })[] ).map((row) => ({ ...toPhoto(row), userId: row.userId, email: row.email, mime: row.mime, bytes: row.bytes })); } // A member's own folder, newest first. No JOIN: the owner is the caller. This // is the one listing that carries `recipe` — the look to reopen the photo with. export function listPhotosByUser(userId: number): MyPhoto[] { return ( db .prepare( `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history FROM photos WHERE user_id = ? ORDER BY photos.id DESC`, ) .all(userId) as MyPhotoRow[] ).map(toMyPhoto); } // Admin listing: one row per account with how many photos it owns. Blocked and // removed accounts stay listed — a removed one has to be findable to restore it. export type AdminUser = { id: number; email: string; createdAt: string; photos: number; avatar: string | null; blocked: number; deletedAt: string | null; }; export function listUsersWithCounts(): AdminUser[] { return db .prepare( `SELECT users.id AS id, users.email AS email, users.created_at AS createdAt, users.avatar AS avatar, users.blocked AS blocked, users.deleted_at AS deletedAt, COUNT(photos.id) AS photos FROM users LEFT JOIN photos ON photos.user_id = users.id GROUP BY users.id ORDER BY users.id`, ) .all() as AdminUser[]; } // ---- moderation ------------------------------------------------------------- // Blocking and removing both drop the account's live sessions: the state has to // take effect on the next request, not whenever the cookie happens to expire. export function setUserBlocked(id: number, blocked: boolean): boolean { const info = db.prepare('UPDATE users SET blocked = ? WHERE id = ?').run(blocked ? 1 : 0, id); if (info.changes > 0 && blocked) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); return info.changes > 0; } export function setUserRemoved(id: number, removed: boolean): boolean { const info = db .prepare('UPDATE users SET deleted_at = ? WHERE id = ?') .run(removed ? now() : null, id); if (info.changes > 0 && removed) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); return info.changes > 0; } // The final act: the row and everything hanging off it. Returns the files the // caller has to unlink — the rows are the only index of what is on disk. export function deleteUser(id: number): { photos: string[]; avatar: string | null } | undefined { const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as | { avatar: string | null } | undefined; if (!row) return undefined; const photos = (db.prepare('SELECT file FROM photos WHERE user_id = ?').all(id) as { file: string }[]).map( (r) => r.file, ); if (db.prepare('DELETE FROM users WHERE id = ?').run(id).changes === 0) return undefined; db.prepare('DELETE FROM photos WHERE user_id = ?').run(id); db.prepare('DELETE FROM recipes WHERE user_id = ?').run(id); db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id); return { photos, avatar: row.avatar }; } // Profile edits. The email column is UNIQUE, so a taken address comes back as // false rather than a thrown constraint; the password uses the same hash the // sign-up path writes. export function updateUserEmail(id: number, email: string): boolean { try { db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id); return true; } catch (err) { if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return false; throw err; } } export function setUserPassword(id: number, password: string): void { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id); } export function countPhotos(userId: number): number { return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n; } export function createPhoto( userId: number, file: string, mime: string, bytes: number, meta?: PhotoMeta, ): Photo { const ts = now(); const info = db .prepare( `INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta, consent, recipe) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null, meta?.consent === false ? 0 : 1, meta?.recipe === undefined ? null : JSON.stringify(meta.recipe), ); // A fresh upload is a strip photo until the curator moves it to a live slot. return { id: Number(info.lastInsertRowid), createdAt: ts, slot: 'strip', tag: meta?.tag ?? null, title: meta?.title ?? null, meta: meta?.meta ?? null, consent: meta?.consent !== false, }; } // Re-saving a photo the caller already owns: the pixels are replaced in place // and the look the row carried steps into its history, newest first and capped // — the only way back to it, since the bytes it described are gone. A row that // is not theirs, or not there, comes back undefined. export function replacePhoto( userId: number, id: number, file: string, mime: string, bytes: number, meta?: PhotoMeta, ): MyPhoto | undefined { const row = db .prepare( `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history FROM photos WHERE id = ? AND user_id = ?`, ) .get(id, userId) as MyPhotoRow | undefined; if (!row) return undefined; const before = toMyPhoto(row); // Only a save that carried a new look is a version of anything, and the log // is capped on the way in so the column can never grow past it. const history = meta?.recipe === undefined || before.recipe === null ? before.history : [before.recipe, ...before.history].slice(0, PHOTO_HISTORY_MAX); db.prepare( `UPDATE photos SET file = ?, mime = ?, bytes = ?, tag = ?, title = ?, meta = ?, consent = ?, recipe = ?, history = ? WHERE id = ?`, ).run( file, mime, bytes, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null, meta?.consent === false ? 0 : 1, meta?.recipe === undefined ? JSON.stringify(before.recipe) : JSON.stringify(meta.recipe), JSON.stringify(history), id, ); return toMyPhoto( db .prepare( `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history FROM photos WHERE id = ?`, ) .get(id) as MyPhotoRow, ); } // The uploader's own toggle: may this photo show on the landing strip? export function setPhotoConsent(userId: number, id: number, consent: boolean): boolean { return ( db.prepare('UPDATE photos SET consent = ? WHERE id = ? AND user_id = ?').run(consent ? 1 : 0, id, userId) .changes > 0 ); } // The stored file name is only ever used through here, and callers must still // reject anything that is not a single path segment (see server.ts). export function photoFile(id: number): { file: string; mime: string } | undefined { return db.prepare('SELECT file, mime FROM photos WHERE id = ?').get(id) as | { file: string; mime: string } | undefined; } export function deletePhoto(id: number): string | undefined { const row = db.prepare('SELECT file FROM photos WHERE id = ?').get(id) as { file: string } | undefined; if (!row) return undefined; db.prepare('DELETE FROM photos WHERE id = ?').run(id); return row.file; } // The owner's own delete: the user_id in the WHERE is the whole authorisation, // so a member can never name someone else's row. export function deletePhotoOf(userId: number, id: number): string | undefined { const row = db.prepare('SELECT file FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as | { file: string } | undefined; if (!row) return undefined; db.prepare('DELETE FROM photos WHERE id = ? AND user_id = ?').run(id, userId); return row.file; } // Curating, not moderating: where this photo is allowed to surface. export function setPhotoSlot(id: number, slot: PhotoSlot): boolean { return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0; } export function deleteAllPhotos(): string[] { const files = (db.prepare('SELECT file FROM photos').all() as { file: string }[]).map((r) => r.file); db.prepare('DELETE FROM photos').run(); return files; } // --- analytics ------------------------------------------------------------- // One row per page view or feature click. Nothing that identifies a visitor is // stored: the address is turned into a salted hash (enough to count uniques) // and into a coarse place at insert time, then dropped. See `createEvent`. export type EventKind = 'view' | 'click'; export interface EventInput { kind: EventKind; path: string; target: string | null; visitor: string; userId: number | null; country: string | null; region: string | null; city: string | null; browser: string | null; os: string | null; device: string | null; } export function createEvent(e: EventInput): void { db.prepare( `INSERT INTO events (at, kind, path, target, visitor, user_id, country, region, city, browser, os, device) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( new Date().toISOString(), e.kind, e.path, e.target, e.visitor, e.userId, e.country, e.region, e.city, e.browser, e.os, e.device, ); } // One grouped count, in the shape every chart on the stats page wants. export interface EventBucket { key: string; n: number; } export interface EventStats { days: number; totals: { views: number; clicks: number; visitors: number }; series: { date: string; views: number; clicks: number }[]; pages: EventBucket[]; targets: EventBucket[]; countries: EventBucket[]; regions: EventBucket[]; cities: EventBucket[]; browsers: EventBucket[]; systems: EventBucket[]; devices: EventBucket[]; } // The columns a group-by may name. An allowlist, not interpolation: the value // reaches a SQL string, so it must never come from the request unchecked. const BUCKET_COLUMN = { pages: 'path', targets: 'target', countries: 'country', regions: 'region', cities: 'city', browsers: 'browser', systems: 'os', devices: 'device', } as const; export function eventStats(days: number, limit = 12): EventStats { const since = new Date(Date.now() - days * 86_400_000).toISOString(); const grouped = (column: string, kind: EventKind | null): EventBucket[] => db .prepare( `SELECT ${column} AS key, COUNT(*) AS n FROM events WHERE at >= ? AND ${column} IS NOT NULL AND ${column} <> '' AND (? IS NULL OR kind = ?) GROUP BY ${column} ORDER BY n DESC, key ASC LIMIT ?`, ) .all(since, kind, kind, limit) as EventBucket[]; const totals = db .prepare( `SELECT SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views, SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks, COUNT(DISTINCT visitor) AS visitors FROM events WHERE at >= ?`, ) .get(since) as { views: number | null; clicks: number | null; visitors: number }; // One point per calendar day (UTC), including days with no traffic, so the // chart's x-axis is a real timeline and not just the days that had hits. const seen = new Map(); for (let i = days - 1; i >= 0; i--) { const date = new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10); seen.set(date, { date, views: 0, clicks: 0 }); } const rows = db .prepare( `SELECT substr(at, 1, 10) AS date, SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views, SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks FROM events WHERE at >= ? GROUP BY date`, ) .all(since) as { date: string; views: number; clicks: number }[]; for (const row of rows) if (seen.has(row.date)) seen.set(row.date, row); return { days, totals: { views: totals.views ?? 0, clicks: totals.clicks ?? 0, visitors: totals.visitors }, series: [...seen.values()], pages: grouped(BUCKET_COLUMN.pages, 'view'), targets: grouped(BUCKET_COLUMN.targets, 'click'), countries: grouped(BUCKET_COLUMN.countries, 'view'), regions: grouped(BUCKET_COLUMN.regions, 'view'), cities: grouped(BUCKET_COLUMN.cities, 'view'), browsers: grouped(BUCKET_COLUMN.browsers, 'view'), systems: grouped(BUCKET_COLUMN.systems, 'view'), devices: grouped(BUCKET_COLUMN.devices, 'view'), }; }