import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import { 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, createPhoto, createRecipe, createSession, createUser, deleteAllPhotos, deletePhoto, deleteRecipe, deleteSession, findUserByEmail, listPhotos, listPhotosWithOwner, listRecipes, photoFile, photoPath, sessionUser, updateRecipe, verifyPassword, type Recipe, type User, } 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()); 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' }; // 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; } // ---- routes ---- app.get('/api/health', async () => ({ ok: true })); 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' }); setSession(req, reply, createSession(user.id)); return reply.status(201).send({ user }); }); 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' }); setSession(req, reply, createSession(row.id)); return reply.status(200).send({ user: { id: row.id, email: row.email } }); }); 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 ?? null }); }); app.get('/api/recipes', async (req, reply) => { const user = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); return reply.status(200).send({ recipes: listRecipes(user.id) }); }); app.post('/api/recipes', async (req, reply) => { const user = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); 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 = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); 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 = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); 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 ------------------------------------------- // 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() })); app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { const user = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); 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' }); if (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); return reply.status(201).send({ photo }); }); 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); }); // ---- 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. } } 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() }); }); 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 }); }); app .listen({ port: PORT, host: HOST }) .catch((err) => { app.log.error(err); process.exit(1); });