import { recipeFile } from './recipeFile'; import { sendVerificationMail } from './mailer'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import { createHash, randomBytes } from 'node:crypto'; import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { basename } from 'node:path'; import { MAX_PHOTO_BYTES, MAX_PHOTOS_PER_USER, MAX_RECIPE_BYTES, SESSION_COOKIE, SESSION_MAX_AGE_S, DUMMY_HASH, countPhotos, createEmailVerification, createEvent, createPhoto, createRecipe, createSession, createUser, deleteAllPhotos, deletePhoto, deletePhotoOf, deleteRecipe, deleteSession, deleteUser, eventStats, findUserByEmail, findUserById, isPhotoSlot, listPhotos, listPhotosByUser, listPhotosWithOwner, listRecipes, listUsersWithCounts, replacePhoto, avatarPath, photoFile, photoPath, photoPreset, rateLook, ratingsFor, sessionUser, setPhotoSlots, setPhotoConsent, setUserAvatar, setUserBlocked, setUserPassword, setUserRemoved, updateRecipe, updateUserEmail, userAvatar, verifyEmailToken, verifyPassword, type PhotoMeta, type Recipe, type User, type EventKind, type PhotoSlot, } from './db'; const PORT = Number(process.env.PORT || 3000); const HOST = '0.0.0.0'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const MAX_EMAIL = 254; const MIN_PASSWORD = 8; const MAX_PASSWORD = 200; const MAX_NAME = 120; // Comma-separated allowlist from the environment. An allowlist over a role // column keeps the privilege out of the database entirely: no migration, and // no endpoint that could ever elevate someone. const ADMIN_EMAILS = new Set( (process.env.ADMIN_EMAILS ?? '') .split(',') .map((s) => s.trim().toLowerCase()) .filter(Boolean), ); const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase()); // What an account is worth. A signup proves nothing until the address it gave // is confirmed, so an unverified account is a guest with a name: the PRO tier, // its own listings and every write stay shut. Admins come from the // deployment's own allowlist — trusted by construction, so no letter is needed // and a broken relay cannot lock the operator out of their own site. const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user); // The public shape of an account. `admin` is the allowlist's answer, so the // client can decide whether to offer /admin without a second round trip — and // the server still enforces it on every admin route below. // `verified` is the studio's PRO gate: true only for a proven address. // `avatar` is a URL the client can drop straight into an , or null when // the account never picked a picture. The `v` is the stored file's own name, so // the URL changes with the picture and can be cached hard. const publicUser = (user: User) => ({ id: user.id, email: user.email, admin: isAdmin(user), verified: isVerified(user), avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null, }); const app = Fastify({ logger: true, bodyLimit: 1024 * 1024, // The API is only reachable through nginx, so the forwarded headers are the // only source of truth for the original scheme (see the Secure cookie flag). trustProxy: true, }); // Bodyless DELETE/logout requests still often carry Content-Type: application/json. app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { const raw = (body as string).trim(); if (raw === '') return done(null, undefined); try { done(null, JSON.parse(raw)); } catch { done(Object.assign(new Error('invalid JSON body'), { statusCode: 400 })); } }); // Photo uploads are the raw image bytes, not multipart: one file per request // needs no boundary parsing, so no dependency and no parser attack surface. app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => { done(null, body); }); // ---- rate limiting -------------------------------------------------------- // Fixed window keyed on what the caller is trying to abuse — an email, or a // user id — rather than an address: the API sits behind two proxies, so a // request's source address is not something it can honestly trust, but the // account being attacked cannot be rotated by the attacker. // ponytail: in-memory, one container. Swap for @fastify/rate-limit + Redis if // the API is ever scaled beyond that. function limiter(max: number, windowMs: number) { const hits = new Map(); return (key: string): boolean => { const t = Date.now(); if (hits.size > 5000) for (const [k, v] of hits) if (v.until <= t) hits.delete(k); const row = hits.get(key); if (!row || row.until <= t) { hits.set(key, { n: 1, until: t + windowMs }); return true; } row.n += 1; return row.n <= max; }; } const allowLogin = limiter(20, 15 * 60_000); const allowSignup = limiter(5, 60 * 60_000); const allowUpload = limiter(60, 60 * 60_000); const tooMany = (reply: FastifyReply) => reply.header('retry-after', '900').status(429).send({ error: 'too_many_requests' }); // ---- image sniffing ------------------------------------------------------- // The declared Content-Type is a claim; the first bytes are evidence. Both must // agree, and only these three formats are accepted — SVG in particular is never // accepted, because it is a script container that would run on our origin. type ImageMime = 'image/jpeg' | 'image/png' | 'image/webp'; const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); function sniffImage(buf: Buffer): ImageMime | null { if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return 'image/png'; if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'; return null; } const EXT: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }; const AVATAR_MIME: Record = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' }; // Single error shape for the whole API: { error: "..." } app.setErrorHandler((err, req, reply) => { const e = err as { statusCode?: number; message?: string }; const status = e.statusCode && e.statusCode >= 400 ? e.statusCode : 500; if (status >= 500) req.log.error(err); reply.status(status).send({ error: status >= 500 ? 'internal_error' : (e.message ?? 'error') }); }); app.setNotFoundHandler((_req, reply) => reply.status(404).send({ error: 'not_found' })); // ---- cookie helpers (hand-rolled: only one cookie, no plugin needed) ---- function cookieOf(req: FastifyRequest, name: string): string | undefined { const raw = req.headers.cookie; if (!raw) return undefined; for (const part of raw.split(';')) { const eq = part.indexOf('='); if (eq === -1) continue; if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); } return undefined; } function setSession(req: FastifyRequest, reply: FastifyReply, token: string): void { // Secure only where the visitor actually arrived over TLS: nginx forwards the // original scheme, so the cookie is hardened in production without breaking // local http access to the same build. const secure = req.protocol === 'https' ? '; Secure' : ''; reply.header( 'set-cookie', `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`, ); } function clearSession(reply: FastifyReply): void { reply.header('set-cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); } // ---- validation at the trust boundary ---- type Json = Record; function bodyOf(req: FastifyRequest): Json | undefined { const b = req.body as unknown; return b !== null && typeof b === 'object' && !Array.isArray(b) ? (b as Json) : undefined; } function credentials(b: Json): { email: string; password: string } | string { const email = typeof b.email === 'string' ? b.email.trim().toLowerCase() : ''; const password = typeof b.password === 'string' ? b.password : ''; if (!email || email.length > MAX_EMAIL || !EMAIL_RE.test(email)) return 'invalid email'; if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) return `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters`; return { email, password }; } function recipePayload(b: Json): { name: string; recipe: Json } | string { const name = typeof b.name === 'string' ? b.name.trim() : ''; const recipe = b.recipe; if (!name || name.length > MAX_NAME) return `name must be 1-${MAX_NAME} characters`; if (recipe === null || typeof recipe !== 'object' || Array.isArray(recipe)) return 'recipe must be an object'; if (Buffer.byteLength(JSON.stringify(recipe)) > MAX_RECIPE_BYTES) return 'recipe too large'; return { name, recipe: recipe as Json }; } function auth(req: FastifyRequest): User | undefined { const token = cookieOf(req, SESSION_COOKIE); return token ? sessionUser(token) : undefined; } // The gate every personal route takes instead of `auth`. Two different // refusals, because the studio acts on them differently: 401 sends a guest to // the sign-in dialog, 403 asks a signed-in account to open its mail. function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined { const user = auth(req); if (!user) { void reply.status(401).send({ error: 'unauthorized' }); return undefined; } if (!isVerified(user)) { void reply.status(403).send({ error: 'email not verified' }); return undefined; } return user; } // The verification link has to work from wherever the visitor actually // arrived — the deployment's domain, an IP:port, localhost in development. // nginx forwards the original Host and scheme, so the request already knows // both; the header is a chain, and the first hop is the one the browser used. function originOf(req: FastifyRequest): string { const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v)?.split(',')[0].trim(); const host = first(req.headers['x-forwarded-host']) || req.headers.host || ''; const proto = first(req.headers['x-forwarded-proto']) || req.protocol || 'http'; return host ? `${proto}://${host}` : ''; } // Mints the single live token and hands the link to the mailer. The URL is the // API's own route, so a click needs no page of its own (see the redirect // there). A relay that cannot send is not an error here: the link is in the // log, and the account can ask again. function sendVerification(req: FastifyRequest, user: User): void { const token = createEmailVerification(user.id); sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, (msg) => req.log.info(msg)); } // ---- analytics ------------------------------------------------------------ // The page counter. It stores nothing that identifies a visitor: the address // becomes a salted hash (enough to count uniques) and a coarse place, then it // is dropped. A full UA/GeoIP database would be a dependency for numbers nobody // acts on at this size, so both readings stay deliberately plain. const MAX_PATH = 120; const MAX_TARGET = 60; const isEventKind = (v: unknown): v is EventKind => v === 'view' || v === 'click'; // The address the visitor actually used. nginx (and the proxy in front of it) // append to X-Forwarded-For, so the left-most entry is the client; `req.ip` is // the fallback for a direct call. function clientIp(req: FastifyRequest): string { const chain = req.headers['x-forwarded-for']; const raw = Array.isArray(chain) ? chain[0] : chain; return raw?.split(',')[0]?.trim() || req.ip || ''; } const STATS_SALT = process.env.STATS_SALT ?? 'recipescam-analytics'; const visitorOf = (ip: string) => createHash('sha256').update(`${STATS_SALT}:${ip}`).digest('hex').slice(0, 16); // Family + major version, the OS, and the form factor. Order matters: Edge and // Opera also claim Chrome, and Safari claims nothing else. const BROWSERS: [string, RegExp][] = [ ['Edge', /Edg\/(\d+)/], ['Opera', /OPR\/(\d+)/], ['Samsung Internet', /SamsungBrowser\/(\d+)/], ['Firefox', /Firefox\/(\d+)/], ['Chrome', /Chrome\/(\d+)/], // Safari's own token comes last and may sit past "Mobile/…", which is why the // gap between the version and the name is not just digits. ['Safari', /Version\/(\d+)[^)]* Safari/], ]; const SYSTEMS: [string, RegExp][] = [ ['Windows', /Windows NT/], ['Android', /Android/], ['iOS', /iPhone|iPad|iPod/], ['macOS', /Mac OS X/], ['Linux', /Linux|X11/], ]; function parseUa(ua: string): { browser: string | null; os: string | null; device: string } { const u = ua.toLowerCase(); const device = /bot|crawler|spider|httpclient|curl|wget|python-requests/.test(u) ? 'bot' : /ipad|tablet|android(?!.*mobile)/.test(u) ? 'tablet' : /mobi|iphone|ipod|android/.test(u) ? 'mobile' : 'desktop'; const match = (table: [string, RegExp][]) => { for (const [name, re] of table) { const m = re.exec(ua); // Some patterns carry a version group, some only name the family. if (m) return m[1] ? `${name} ${m[1]}` : name; } return null; }; return { browser: match(BROWSERS), os: match(SYSTEMS), device }; } interface GeoPlace { country: string; region: string; city: string; } // Resolved at insert, cached per address. ip-api's free endpoint needs no key; // when it is unreachable or rate-limited the row keeps a null place and the // stats page shows "unknown" rather than failing the request. const geoCache = new Map(); const PRIVATE_IP = /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|f[cd]|localhost)/i; async function lookupGeo(ip: string): Promise { if (!ip || PRIVATE_IP.test(ip)) return null; const hit = geoCache.get(ip); if (hit !== undefined) return hit; if (geoCache.size > 2000) geoCache.clear(); let place: GeoPlace | null = null; try { const res = await fetch( `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city`, { signal: AbortSignal.timeout(1500) }, ); const b = (await res.json()) as { status?: string; country?: string; regionName?: string; city?: string }; if (b.status === 'success') place = { country: b.country ?? '', region: b.regionName ?? '', city: b.city ?? '' }; } catch { // Offline, blocked or rate-limited: a missing place, not a failed visit. } geoCache.set(ip, place); return place; } // ---- routes ---- app.get('/api/health', async () => ({ ok: true })); // The counter's one write. Public and unauthenticated by design: it is fired by // a beacon from every page, so it must never be a way to probe who is signed in // — it answers 204 whatever happens and echoes nothing back. const allowTrack = limiter(300, 60_000); app.post('/api/events', async (req, reply) => { const b = bodyOf(req); const ip = clientIp(req); if (!b || !isEventKind(b.kind) || !allowTrack(ip || 'unknown')) return reply.status(204).send(); const text = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : ''); const ua = parseUa(typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''); const geo = await lookupGeo(ip); createEvent({ kind: b.kind, path: text(b.path, MAX_PATH) || '/', target: text(b.target, MAX_TARGET) || null, visitor: visitorOf(ip || 'unknown'), userId: auth(req)?.id ?? null, country: geo?.country || null, region: geo?.region || null, city: geo?.city || null, browser: ua.browser, os: ua.os, device: ua.device, }); return reply.status(204).send(); }); // The film strip's ratings. Public and unauthenticated for the same reason the // counter is: any visitor may score a frame once, and the vote is held against // the salted-address hash rather than an account. The subject is a photo id or // a built-in look's tag, so both kinds of frame are rated through one route. const allowRate = limiter(120, 60_000); const RATING_KEY = /^[A-Za-z0-9:_-]{1,64}$/; app.get('/api/ratings', async (req) => ({ ratings: ratingsFor(visitorOf(clientIp(req) || 'unknown')) })); app.post('/api/ratings', async (req, reply) => { const b = bodyOf(req); const ip = clientIp(req); const key = typeof b?.key === 'string' ? b.key.trim() : ''; const stars = Math.round(Number(b?.stars)); if (!RATING_KEY.test(key) || !Number.isFinite(stars) || stars < 1 || stars > 5) return reply.status(400).send({ error: 'invalid rating' }); if (!allowRate(ip || 'unknown')) return tooMany(reply); const visitor = visitorOf(ip || 'unknown'); rateLook(key, visitor, stars); return reply.status(200).send({ key, rating: ratingsFor(visitor)[key] }); }); app.post('/api/auth/signup', async (req, reply) => { const b = bodyOf(req); if (!b) return reply.status(400).send({ error: 'invalid body' }); const creds = credentials(b); if (typeof creds === 'string') return reply.status(400).send({ error: creds }); if (!allowSignup(creds.email)) return tooMany(reply); if (findUserByEmail(creds.email)) return reply.status(409).send({ error: 'email already registered' }); const user = createUser(creds.email, creds.password); if (!user) return reply.status(409).send({ error: 'email already registered' }); // The session is granted anyway. An unverified account is served at the guest // tier, but it is a guest that can see the banner saying so and ask for its // link again — which needs to be somebody. sendVerification(req, user); setSession(req, reply, createSession(user.id)); return reply.status(201).send({ user: publicUser(user) }); }); // Where the mail link lands. A plain GET, no session required: the visitor may // well open it in another browser, or on the phone that owns the address. It // answers with a redirect rather than JSON for the same reason — the landing // page is what a browser should show. A bad or expired token is not an error // page, it is the same page saying the link did not work. app.get('/api/auth/verify', async (req, reply) => { const raw = (req.query as { token?: unknown } | undefined)?.token; const userId = typeof raw === 'string' && raw.length <= 128 ? verifyEmailToken(raw) : null; return reply.redirect(`${originOf(req)}/?verified=${userId ? 1 : 0}`, 303); }); // The banner's own button. Capped like signup and keyed on the address, so the // route is not a way to mail a stranger repeatedly. const allowResend = limiter(3, 60 * 60_000); app.post('/api/auth/resend-verification', async (req, reply) => { // `auth`, not `requirePro`: the whole point of the route is the account that // has not passed the gate yet. const user = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); if (isVerified(user)) return reply.status(200).send({ ok: true, verified: true }); if (!allowResend(user.email)) return tooMany(reply); sendVerification(req, user); return reply.status(200).send({ ok: true }); }); app.post('/api/auth/login', async (req, reply) => { const b = bodyOf(req); if (!b || typeof b.email !== 'string' || typeof b.password !== 'string') return reply.status(400).send({ error: 'invalid body' }); const email = b.email.trim().toLowerCase(); if (!allowLogin(email)) return tooMany(reply); const row = findUserByEmail(email); const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH); if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' }); // Moderation answers after the password check, so the state of an account is // not something an attacker can probe without its credentials. if (row.blocked) return reply.status(403).send({ error: 'account blocked' }); if (row.deletedAt) return reply.status(403).send({ error: 'account removed' }); setSession(req, reply, createSession(row.id)); return reply.status(200).send({ user: publicUser(row) }); }); app.post('/api/auth/logout', async (req, reply) => { const token = cookieOf(req, SESSION_COOKIE); if (token) deleteSession(token); clearSession(reply); return reply.status(204).send(); }); app.get('/api/auth/me', async (req, reply) => { // Signed out is an answer, not an error: "who am I?" with no session is // nobody. A 401 here would put a console error on every anonymous visit to // the landing page, which asks the same question to decide what to offer. const user = auth(req); return reply.status(200).send({ user: user ? publicUser(user) : null }); }); // Profile: the signed-in account edits its own email or password. The current // password is required either way, so a stolen cookie alone cannot lock the // owner out — and the login limiter caps guesses at it. app.patch('/api/auth/me', async (req, reply) => { // `auth`, not `requirePro`: editing your own profile is how an unverified // account fixes a mistyped address, so this route stays open to it. const user = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); if (!allowLogin(user.email)) return tooMany(reply); const b = bodyOf(req); if (!b) return reply.status(400).send({ error: 'invalid body' }); const row = findUserByEmail(user.email); const current = typeof b.currentPassword === 'string' ? b.currentPassword : ''; if (!row || !verifyPassword(current, row.password_hash)) return reply.status(403).send({ error: 'invalid password' }); let email = user.email; let emailVerified = user.emailVerified; if (b.email !== undefined) { const next = typeof b.email === 'string' ? b.email.trim().toLowerCase() : ''; if (!next || next.length > MAX_EMAIL || !EMAIL_RE.test(next)) return reply.status(400).send({ error: 'invalid email' }); if (next !== user.email) { if (!updateUserEmail(user.id, next)) return reply.status(409).send({ error: 'email already registered' }); email = next; // The tier follows the address that earned it: a new one is unproven // until its own link is followed, so the flag goes back to 0 (the update // cleared the row) and a letter goes out. emailVerified = 0; sendVerification(req, { ...user, email }); } } if (b.password !== undefined) { const password = typeof b.password === 'string' ? b.password : ''; if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) return reply.status(400).send({ error: `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters` }); setUserPassword(user.id, password); } return reply.status(200).send({ user: publicUser({ ...user, email, emailVerified }) }); }); app.get('/api/recipes', async (req, reply) => { const user = requirePro(req, reply); if (!user) return; return reply.status(200).send({ recipes: listRecipes(user.id) }); }); app.post('/api/recipes', async (req, reply) => { const user = requirePro(req, reply); if (!user) return; const b = bodyOf(req); const payload = b && recipePayload(b); if (typeof payload === 'string' || !payload) return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' }); const recipe: Recipe = createRecipe(user.id, payload.name, payload.recipe); return reply.status(201).send({ recipe }); }); app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => { const user = requirePro(req, reply); if (!user) return; const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' }); const b = bodyOf(req); const payload = b && recipePayload(b); if (typeof payload === 'string' || !payload) return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' }); const recipe = updateRecipe(user.id, id, payload.name, payload.recipe); if (!recipe) return reply.status(404).send({ error: 'recipe not found' }); return reply.status(200).send({ recipe }); }); app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => { const user = requirePro(req, reply); if (!user) return; const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' }); if (!deleteRecipe(user.id, id)) return reply.status(404).send({ error: 'recipe not found' }); return reply.status(204).send(); }); // ---- contributed strip photos ------------------------------------------- // The frame's own labels ride the query string: the body is the raw image, so // there is no JSON envelope to put them in. Capped and control-stripped here, // because they are drawn and stored rather than trusted. const META_MAX = { tag: 64, title: 120, meta: 160 } as const; // The recipe travels as one URL-encoded query parameter beside the labels; this // is the ceiling of what the studio can hand back (a real one is well under 1KB). const RECIPE_PARAM_MAX = 3000; function cleanMeta(value: unknown, max: number): string | null { if (typeof value !== 'string') return null; // eslint-disable-next-line no-control-regex const text = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max); return text || null; } function photoMeta(req: FastifyRequest): PhotoMeta { const q = (req.query ?? {}) as Record; return { tag: cleanMeta(q.tag, META_MAX.tag), title: cleanMeta(q.title, META_MAX.title), meta: cleanMeta(q.meta, META_MAX.meta), // The look that made the pixels, so the studio can open the photo again. // It rides the query string like the labels do (the body is the raw image), // so it is capped here: a recipe this app writes is well under a kilobyte. recipe: parseRecipeParam(q.recipe), // Consent is the uploader's, and only an explicit false opts out. consent: q.consent !== '0', }; } // Anything the studio could not read back as a recipe object is dropped, never // stored half-parsed: the row must not carry a blob that breaks the folder. function parseRecipeParam(raw: unknown): unknown { if (typeof raw !== 'string' || raw.length === 0) return undefined; if (raw.length > RECIPE_PARAM_MAX) return undefined; try { const value: unknown = JSON.parse(raw); return value && typeof value === 'object' ? value : undefined; } catch { return undefined; } } // Anyone may read the strip; only a signed-in account may add to it. The bytes // are written under a server-generated name, so a caller's own filename never // reaches the filesystem, and the row is the only place the real mime lives. app.get('/api/photos', async () => ({ photos: listPhotos() })); // The caller's own folder — the count the studio's SAVE PHOTO shows comes from // here, and the admin drill-down reads the same rows through /admin/photos. app.get('/api/photos/mine', async (req, reply) => { const user = requirePro(req, reply); if (!user) return; return reply.status(200).send({ photos: listPhotosByUser(user.id) }); }); app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { const user = requirePro(req, reply); if (!user) return; if (!allowUpload(String(user.id))) return tooMany(reply); const body = req.body; if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' }); if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' }); const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); const mime = sniffImage(body); if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' }); // The quota is a fair-use cap on members, not on the curator. if (!isAdmin(user) && countPhotos(user.id) >= MAX_PHOTOS_PER_USER) return reply.status(429).send({ error: 'photo quota reached' }); const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`; writeFileSync(photoPath(file), body, { flag: 'wx' }); const photo = createPhoto(user.id, file, mime, body.length, photoMeta(req)); return reply.status(201).send({ photo }); }); // Re-saving one of the caller's own photos: the same bytes-in, meta-on-the- // query shape as the upload, but it lands on the row they named instead of // making a new one. The pixels it displaces are gone for good, so the look they // carried steps into the row's history (see replacePhoto) and the old file is // unlinked. Owner only — the user_id in the WHERE is the authorisation. app.put<{ Params: { id: string } }>( '/api/photos/:id', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { const user = requirePro(req, reply); if (!user) return; const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); if (!allowUpload(String(user.id))) return tooMany(reply); const body = req.body; if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' }); if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' }); const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); const mime = sniffImage(body); if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' }); const previous = photoFile(id); const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`; writeFileSync(photoPath(file), body, { flag: 'wx' }); const photo = replacePhoto(user.id, id, file, mime, body.length, photoMeta(req)); if (!photo) { unlink(file); return reply.status(404).send({ error: 'photo not found' }); } if (previous && previous.file !== file) unlink(previous.file); return reply.status(200).send({ photo }); }, ); // A profile picture is the same deal as a photo: raw bytes, sniffed, written // under a server-generated name. The picture it replaces goes with it. app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { const user = requirePro(req, reply); if (!user) return; if (!allowUpload(String(user.id))) return tooMany(reply); const body = req.body; if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' }); if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' }); const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); const mime = sniffImage(body); if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' }); const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`; writeFileSync(avatarPath(file), body, { flag: 'wx' }); const previous = setUserAvatar(user.id, file); if (previous) unlinkAvatar(previous); return reply.status(200).send({ user: publicUser({ ...user, avatar: file }) }); }); // Public on purpose: an avatar sits next to a name on the landing page, so // there is nothing here a session would protect. app.get<{ Params: { id: string } }>('/api/users/:id/avatar', async (req, reply) => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' }); const file = userAvatar(id); const type = file ? AVATAR_MIME[file.split('.').pop() ?? ''] : undefined; if (!file || !type) return reply.status(404).send({ error: 'not_found' }); let data: Buffer; try { data = readFileSync(avatarPath(file)); } catch { return reply.status(404).send({ error: 'not_found' }); } // The URL carries the file's own name as a version, so it can never go stale. return reply .header('content-type', type) .header('cache-control', 'public, max-age=31536000, immutable') .send(data); }); app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' }); const row = photoFile(id); // The column is server-generated, but re-check it on the way out: a single // path segment is the only thing that can ever be opened. if (!row || basename(row.file) !== row.file) return reply.status(404).send({ error: 'not_found' }); let data: Buffer; try { data = readFileSync(photoPath(row.file)); } catch { return reply.status(404).send({ error: 'not_found' }); } return reply .header('content-type', row.mime) .header('x-content-type-options', 'nosniff') // Belt and braces on top of the mime allowlist: even a hostile still cannot // act as a document on this origin. .header('content-security-policy', "default-src 'none'; sandbox") // Short, not immutable: an admin deleting a contribution has to be able to // take it off the web, and a cached copy would outlive the removal. .header('cache-control', 'public, max-age=60') .send(data); }); // The QR card's payload: the `.recipe` file the app reads back on IMPORT, built // from the look the photo was uploaded with. Public like the strip, but only // for a row the curator ticked into the `qr` section — that checkbox is the // whole permission. Everything else is a 404 rather than a 403, so the route // cannot be used to probe which photos carry a look. app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req, reply) => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' }); const row = photoPreset(id); if (!row || row.recipe === null || !row.slots.includes('qr')) return reply.status(404).send({ error: 'not_found' }); return reply .header('content-type', 'application/xml; charset=utf-8') .header('x-content-type-options', 'nosniff') .header('content-disposition', `attachment; filename="recipescam-${id}.recipe"`) .header('cache-control', 'public, max-age=60') .send(recipeFile(row.recipe)); }); // The uploader's own permission switch: may this photo show on the landing // strip? Only the owner may flip it (an admin curates the slot, not the // consent), and only their own row is reachable — the user_id in the WHERE is // the authorisation. app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => { const user = requirePro(req, reply); if (!user) return; const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); const body = (req.body ?? {}) as { consent?: unknown }; if (typeof body.consent !== 'boolean') return reply.status(400).send({ error: 'invalid consent' }); if (!setPhotoConsent(user.id, id, body.consent)) return reply.status(404).send({ error: 'photo not found' }); return reply.status(200).send({ id, consent: body.consent }); }); // Removing one of your own photos. An admin may remove anyone's from here too, // so the folder and the moderation screen share one route. The row is only // dropped when the caller owns it (or curates the whole strip), and the file // goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink. app.delete<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => { const user = requirePro(req, reply); if (!user) return; const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); const file = isAdmin(user) ? deletePhoto(id) : deletePhotoOf(user.id, id); if (!file) return reply.status(404).send({ error: 'photo not found' }); unlink(file); return reply.status(204).send(); }); // ---- admin --------------------------------------------------------------- // Moderation only: the allowlist can list everything and clean up. There is // deliberately no endpoint here that grants the privilege itself. function admin(req: FastifyRequest): User | { status: number } { const user = auth(req); if (!user) return { status: 401 }; if (!isAdmin(user)) return { status: 403 }; return user; } function unlink(file: string): void { try { unlinkSync(photoPath(file)); } catch { // Already gone; the row is what matters. } } function unlinkAvatar(file: string): void { try { unlinkSync(avatarPath(file)); } catch { // Already gone; the row is what matters. } } // Accounts and how much each one has contributed — the "who is this" half of // moderation. `admin` is the allowlist's answer, not a stored column. app.get('/api/admin/users', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); return reply.status(200).send({ users: listUsersWithCounts().map((u) => ({ ...u, avatar: u.avatar ? `/api/users/${u.id}/avatar?v=${u.avatar.split('.')[0]}` : null, admin: ADMIN_EMAILS.has(u.email), blocked: !!u.blocked, removed: !!u.deletedAt, })), }); }); // Moderation of an account. `blocked` stops it signing in; `removed` takes it // (and its photos) off the site while staying restorable. Both are reversible, // which is why they share one route — the hard delete is the DELETE below. // An allowlisted account is never a target: the allowlist is the only source of // admin privilege, so this also makes "delete yourself" impossible. function moderatable(reply: FastifyReply, id: number): number | null { if (!Number.isInteger(id) || id <= 0) { reply.status(404).send({ error: 'user not found' }); return null; } const target = findUserById(id); if (!target) { reply.status(404).send({ error: 'user not found' }); return null; } if (ADMIN_EMAILS.has(target.email.toLowerCase())) { reply.status(403).send({ error: 'cannot modify an admin account' }); return null; } return id; } app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); const id = moderatable(reply, Number(req.params.id)); if (id === null) return reply; const b = bodyOf(req); if (!b) return reply.status(400).send({ error: 'invalid body' }); if (b.blocked !== undefined) { if (typeof b.blocked !== 'boolean') return reply.status(400).send({ error: 'invalid blocked' }); setUserBlocked(id, b.blocked); } if (b.removed !== undefined) { if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' }); setUserRemoved(id, b.removed); } const row = listUsersWithCounts().find((u) => u.id === id); return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } }); }); app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); const id = moderatable(reply, Number(req.params.id)); if (id === null) return reply; const removed = deleteUser(id); if (!removed) return reply.status(404).send({ error: 'user not found' }); for (const file of removed.photos) unlink(file); if (removed.avatar) unlinkAvatar(removed.avatar); return reply.status(204).send(); }); app.get('/api/admin/photos', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); return reply.status(200).send({ photos: listPhotosWithOwner() }); }); // The traffic screen's one read: page views, feature clicks and how they break // down by page, feature, place, browser, system and device. `days` is clamped // rather than rejected — a bad range must not cost the whole page. app.get('/api/admin/stats', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); const asked = Number((req.query as { days?: string }).days); const days = Number.isFinite(asked) ? Math.min(365, Math.max(1, Math.floor(asked))) : 30; return reply.status(200).send(eventStats(days)); }); app.delete<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); const file = deletePhoto(id); if (!file) return reply.status(404).send({ error: 'photo not found' }); unlink(file); return reply.status(204).send(); }); app.delete('/api/admin/photos', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); const files = deleteAllPhotos(); for (const file of files) unlink(file); return reply.status(200).send({ removed: files.length }); }); // Curating: the landing sections this photo is allowed to appear in. A photo // may sit in several at once — each section picks one of its own at random per // visit, so several photos in one section rotate. An empty set takes it off // the landing without deleting the row. app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); const b = bodyOf(req); const slots = b?.slots; if (!Array.isArray(slots) || !slots.every((s) => isPhotoSlot(s))) return reply.status(400).send({ error: 'invalid slots' }); const set: PhotoSlot[] = [...new Set(slots as PhotoSlot[])]; if (!setPhotoSlots(id, set)) return reply.status(404).send({ error: 'photo not found' }); return reply.status(200).send({ id, slots: set }); }); app .listen({ port: PORT, host: HOST }) .catch((err) => { app.log.error(err); process.exit(1); });