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 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); `); // 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`); } } } // `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; }; export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number }; export type PhotoMeta = { tag?: string | null; title?: string | null; meta?: string | null }; // One SELECT list, so the four 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`; 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 Photo[]; } 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 AdminPhoto[]; } // A member's own folder, newest first. No JOIN: the owner is the caller. export function listPhotosByUser(userId: number): Photo[] { return db .prepare(`SELECT ${PHOTO_COLUMNS} FROM photos WHERE user_id = ? ORDER BY photos.id DESC`) .all(userId) as Photo[]; } // 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) .run(userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null); // 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, }; } // 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; }