diff --git a/docker/.env.example b/docker/.env.example index ae97f74..c1a7b16 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,3 +1,7 @@ # Host port the web UI is published on. Everything else is internal: the API is # only reachable through nginx at /api/. WEB_PORT=8090 + +# Comma-separated emails allowed to moderate the landing strip (/admin). +# Leave empty to make nobody an admin. +ADMIN_EMAILS= diff --git a/docker/backend/package.json b/docker/backend/package.json index 0b18882..7fece42 100644 --- a/docker/backend/package.json +++ b/docker/backend/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "tsc", "start": "node dist/server.js", - "dev": "tsx watch src/server.ts" + "dev": "tsx watch src/server.ts", + "test": "node test/security.mjs" }, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 7da132d..98673bc 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -6,10 +6,20 @@ 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; +// Under nginx's `client_max_body_size 4m`, so an over-limit upload is rejected +// with our JSON error instead of nginx's HTML 413. +export const MAX_PHOTO_BYTES = 3 * 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); + export const db = new Database(join(DATA_DIR, 'recipescam.db')); db.pragma('journal_mode = WAL'); @@ -33,8 +43,17 @@ CREATE TABLE IF NOT EXISTS recipes ( 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); `); export type User = { id: number; email: string }; @@ -156,3 +175,59 @@ export function updateRecipe(userId: number, id: number, name: string, recipe: u 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. +export type Photo = { id: number; createdAt: string }; +export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number }; + +export function listPhotos(): Photo[] { + return db + .prepare('SELECT id, created_at AS createdAt FROM photos ORDER BY id DESC') + .all() as Photo[]; +} + +export function listPhotosWithOwner(): AdminPhoto[] { + return db + .prepare( + `SELECT photos.id AS id, photos.created_at AS createdAt, 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[]; +} + +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): Photo { + const ts = now(); + const info = db + .prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)') + .run(userId, file, mime, bytes, ts); + return { id: Number(info.lastInsertRowid), createdAt: ts }; +} + +// 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; +} + +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; +} diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 9e973a9..2de05c0 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -1,16 +1,29 @@ 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, @@ -27,7 +40,24 @@ const MIN_PASSWORD = 8; const MAX_PASSWORD = 200; const MAX_NAME = 120; -const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 }); +// 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) => { @@ -40,6 +70,55 @@ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, } }); +// 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 }; @@ -61,10 +140,14 @@ function cookieOf(req: FastifyRequest, name: string): string | undefined { return undefined; } -function setSession(reply: FastifyReply, token: string): void { +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}`, + `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`, ); } @@ -111,10 +194,11 @@ app.post('/api/auth/signup', async (req, reply) => { 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(reply, createSession(user.id)); + setSession(req, reply, createSession(user.id)); return reply.status(201).send({ user }); }); @@ -123,10 +207,11 @@ app.post('/api/auth/login', async (req, reply) => { 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(reply, createSession(row.id)); + setSession(req, reply, createSession(row.id)); return reply.status(200).send({ user: { id: row.id, email: row.email } }); }); @@ -138,9 +223,11 @@ app.post('/api/auth/logout', async (req, reply) => { }); 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); - if (!user) return reply.status(401).send({ error: 'unauthorized' }); - return reply.status(200).send({ user }); + return reply.status(200).send({ user: user ?? null }); }); app.get('/api/recipes', async (req, reply) => { @@ -183,6 +270,102 @@ app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => 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) => { diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs new file mode 100644 index 0000000..8aec8af --- /dev/null +++ b/docker/backend/test/security.mjs @@ -0,0 +1,260 @@ +// Security self-check for the API. Boots the real server against a throwaway +// DATA_DIR and exercises the boundaries that matter: who may write, what may be +// written, who may read a photo back, and who may moderate the strip. +// +// npm test (from docker/backend/) +// +// Node only — no test framework, no network beyond loopback. +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const PORT = Number(process.env.TEST_PORT || 3411); +const BASE = `http://127.0.0.1:${PORT}/api`; +const ADMIN_EMAIL = 'admin@test.local'; +const DATA_DIR = mkdtempSync(join(tmpdir(), 'recipescam-sec-')); + +// A 1x1 PNG, and the first bytes of a JPEG (all the sniffer looks at). +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); +const JPEG_HEAD = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64)]); + +let pass = 0; +let fail = 0; +const check = (name, ok, detail = '') => { + if (ok) { + pass++; + console.log(`PASS ${name}`); + } else { + fail++; + console.log(`FAIL ${name}${detail ? ` :: ${detail}` : ''}`); + } +}; + +// One cookie jar per actor, so "signed in as A" cannot leak into B. +function actor() { + let cookie = ''; + return { + get cookie() { + return cookie; + }, + async req(path, init = {}) { + const headers = { ...(init.headers ?? {}) }; + if (cookie) headers.cookie = cookie; + const res = await fetch(BASE + path, { ...init, headers }); + const set = res.headers.getSetCookie?.() ?? (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); + for (const line of set) { + const value = line.split(';')[0]; + if (value.startsWith('rc_session=')) cookie = value.endsWith('=') ? '' : value; + } + const text = await res.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: res.status, headers: res.headers, setCookie: set.join(' | '), body }; + }, + signup(email, password = 'supersecret1') { + return this.req('/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + }, + upload(bytes, type) { + return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes }); + }, + }; +} + +// Run the sources, not a possibly stale build: the point of this suite is to +// test the code as written. +const tsx = join(ROOT, 'node_modules/.bin/tsx'); +const entry = existsSync(tsx) ? [tsx, 'src/server.ts'] : ['dist/server.js']; +const server = spawn(process.execPath, entry, { + cwd: ROOT, + env: { ...process.env, PORT: String(PORT), DATA_DIR, ADMIN_EMAILS: ADMIN_EMAIL, NODE_ENV: 'test' }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let serverLog = ''; +server.stdout.on('data', (d) => (serverLog += d)); +server.stderr.on('data', (d) => (serverLog += d)); + +async function waitForServer() { + for (let i = 0; i < 100; i++) { + try { + const res = await fetch(`${BASE}/health`); + if (res.ok) return true; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 100)); + } + return false; +} + +try { + if (!(await waitForServer())) throw new Error(`server never came up:\n${serverLog}`); + + const stamp = Date.now(); + const admin = actor(); + const user = actor(); + const other = actor(); + + // ---- accounts ----------------------------------------------------------- + check('health responds', (await fetch(`${BASE}/health`)).ok); + + const badEmail = await user.signup('not-an-email'); + check('signup rejects a malformed email', badEmail.status === 400, `got ${badEmail.status}`); + const badPw = await user.signup(`short${stamp}@test.local`, 'short'); + check('signup rejects a short password', badPw.status === 400, `got ${badPw.status}`); + + const adminSignup = await admin.signup(ADMIN_EMAIL); + check('admin account signs up', adminSignup.status === 201, `got ${adminSignup.status}`); + const userSignup = await user.signup(`contributor${stamp}@test.local`); + check('contributor account signs up', userSignup.status === 201, `got ${userSignup.status}`); + await other.signup(`other${stamp}@test.local`); + + const cookie = userSignup.setCookie; + check('session cookie is HttpOnly', /HttpOnly/i.test(cookie), cookie); + check('session cookie is SameSite', /SameSite=Lax/i.test(cookie), cookie); + check('session cookie is not Secure over plain http', !/;\s*Secure/i.test(cookie), cookie); + check('session token is 256 bits of hex', /rc_session=[0-9a-f]{64}/.test(cookie), cookie); + + // The proxy hands nginx the public scheme; a TLS visitor must get Secure. + const tls = actor(); + const tlsSignup = await tls.req('/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' }, + body: JSON.stringify({ email: `tls${stamp}@test.local`, password: 'supersecret1' }), + }); + check('session cookie is Secure behind https', /;\s*Secure/i.test(tlsSignup.setCookie), tlsSignup.setCookie); + + // Documented, not fixed: the 409 is a deliberate UX choice and doubles as an + // account-existence oracle. + const dup = await actor().signup(ADMIN_EMAIL); + check('duplicate signup is a 409 (known user-enumeration oracle)', dup.status === 409, `got ${dup.status}`); + + const noUser = await actor().req('/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: `ghost${stamp}@test.local`, password: 'supersecret1' }), + }); + check('unknown email and wrong password look identical', noUser.status === 401, `got ${noUser.status}`); + + const anonMe = await actor().req('/auth/me'); + check('signed out is a 200 with no user', anonMe.status === 200 && anonMe.body?.user === null, JSON.stringify(anonMe.body)); + const ownMe = await user.req('/auth/me'); + check('/auth/me reports the signed-in account', ownMe.body?.user?.email === `contributor${stamp}@test.local`, JSON.stringify(ownMe.body)); + + // ---- rate limiting ------------------------------------------------------ + const brute = actor(); + const bruteEmail = `brute${stamp}@test.local`; + let limited = 0; + for (let i = 0; i < 21; i++) { + const res = await brute.req('/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: bruteEmail, password: 'wrong-password' }), + }); + if (res.status === 429) limited++; + } + check('login attempts are rate limited', limited === 1, `429s: ${limited}`); + + // ---- upload boundary ---------------------------------------------------- + check('guest cannot upload', (await actor().upload(PNG, 'image/png')).status === 401); + check( + 'non-image content-type is refused', + (await user.upload(Buffer.from(''), 'image/svg+xml')).status === 415, + ); + check( + 'svg bytes under an image content-type is refused', + (await user.upload(Buffer.from(''), 'image/jpeg')).status === 415, + ); + check( + 'a declared type that disagrees with the bytes is refused', + (await user.upload(JPEG_HEAD, 'image/png')).status === 415, + ); + check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400); + + const tooBig = Buffer.concat([PNG, Buffer.alloc(4 * 1024 * 1024)]); + check('an over-limit body is refused', (await user.upload(tooBig, 'image/png')).status === 413); + + const created = await user.upload(PNG, 'image/png'); + check('a signed-in member can upload a real PNG', created.status === 201, JSON.stringify(created.body)); + + // ---- what the public may read ------------------------------------------ + const list = await actor().req('/photos'); + const listed = list.body?.photos ?? []; + check('the strip is readable anonymously', list.status === 200); + check( + 'the public strip leaks no owner', + listed.length > 0 && !('userId' in listed[0]) && !('email' in listed[0]) && !('file' in listed[0]), + JSON.stringify(listed[0]), + ); + + const id = created.body?.photo?.id; + const served = await fetch(`${BASE}/photos/${id}/file`); + check('a contributed photo is served', served.status === 200); + check('the served photo keeps its sniffed type', served.headers.get('content-type') === 'image/png'); + check('the served photo is nosniff', served.headers.get('x-content-type-options') === 'nosniff'); + check('the served photo is sandboxed', (served.headers.get('content-security-policy') ?? '').includes('sandbox')); + check('the bytes round-trip intact', Buffer.from(await served.arrayBuffer()).equals(PNG)); + + check('an unknown id is a 404', (await fetch(`${BASE}/photos/999999/file`)).status === 404); + check('a non-numeric id is a 404', (await fetch(`${BASE}/photos/abc/file`)).status === 404); + check( + 'an id cannot escape the uploads directory', + (await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404, + ); + + // ---- moderation --------------------------------------------------------- + check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401); + const forbidden = await user.req('/admin/photos'); + check('a plain member is 403, not 200', forbidden.status === 403, `got ${forbidden.status}`); + + const adminList = await admin.req('/admin/photos'); + const rows = adminList.body?.photos ?? []; + check('an admin lists contributions', adminList.status === 200 && rows.length > 0); + check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? ''))); + check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403); + + const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' }); + check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`); + check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404); + check('the deleted row is gone from the listing', !((await admin.req('/admin/photos')).body?.photos ?? []).some((r) => r.id === id)); + + // ---- quota -------------------------------------------------------------- + const quota = actor(); + await quota.signup(`quota${stamp}@test.local`); + let last = 0; + for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status; + check('uploads are capped per account', last === 429, `13th upload: ${last}`); + + const cleared = await admin.req('/admin/photos', { method: 'DELETE' }); + check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body)); + check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0); + + // ---- pre-existing guarantees still hold --------------------------------- + const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' }); + check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`); + const missing = await actor().req('/nope'); + check('unknown routes keep the single error shape', missing.status === 404 && missing.body?.error === 'not_found'); +} catch (err) { + fail++; + console.log(`FAIL harness :: ${err && err.stack ? err.stack : err}`); + console.log(serverLog.slice(-2000)); +} finally { + server.kill('SIGTERM'); + rmSync(DATA_DIR, { recursive: true, force: true }); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1df2a7c..2ad15eb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -11,6 +11,9 @@ services: restart: unless-stopped environment: DATA_DIR: /data + # Who may moderate the contributed strip: a comma-separated email + # allowlist. Empty means nobody is an admin, which is the safe default. + ADMIN_EMAILS: ${ADMIN_EMAILS:-} volumes: # SQLite (WAL) lives on the host so a rebuild never loses accounts. - ./data:/data diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index bee49e9..b205956 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -6,6 +6,12 @@ server { root /usr/share/nginx/html; index index.html; + # Static only, plus the API proxy below; a stricter CSP needs a per-app tune + # (canvas wasm, the QR image host) and belongs here once it is done. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + gzip on; gzip_comp_level 5; gzip_min_length 1024; @@ -29,7 +35,10 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + # Hand the API the scheme the visitor actually used (TLS is terminated by + # Nginx Proxy Manager in front of this container) so it can mark the session + # cookie Secure. Falls back to nothing when that header is absent. + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; client_max_body_size 4m; } diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx new file mode 100644 index 0000000..aa78e79 --- /dev/null +++ b/docker/frontend/src/Admin.tsx @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useState } from 'react'; +import { api, type AdminPhoto } from './api'; +import { useI18n } from './i18n/I18nProvider'; + +// Moderation screen for the contributed strip: list, delete one, clear all. +// It holds no privilege of its own — the API answers 403 unless the signed-in +// account is on the ADMIN_EMAILS allowlist, so this page is only a viewer. +// ponytail: no pagination. The upload quota caps the table at a handful of +// rows per account; add a page cursor when the strip outgrows one screen. +type State = 'loading' | 'guest' | 'forbidden' | 'ready'; + +export function Admin() { + const { t } = useI18n(); + const [state, setState] = useState('loading'); + const [photos, setPhotos] = useState([]); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState(null); + + const load = useCallback(async () => { + const me = await api.me().catch(() => null); + if (!me?.user) { + setState('guest'); + return; + } + try { + const r = await api.adminListPhotos(); + setPhotos(r.photos); + setState('ready'); + } catch { + setState('forbidden'); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const remove = async (id: number) => { + setBusy(true); + setNote(null); + try { + await api.adminDeletePhoto(id); + setPhotos((prev) => prev.filter((p) => p.id !== id)); + } catch (err) { + setNote(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const clear = async () => { + if (!window.confirm(t('adm.clearConfirm', { n: photos.length }))) return; + setBusy(true); + setNote(null); + try { + const r = await api.adminClearPhotos(); + setPhotos([]); + setNote(t('adm.removed', { n: r.removed })); + } catch (err) { + setNote(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {t('adm.back')} + {t('adm.title')} + + {t('adm.total', { n: photos.length })} + {state === 'ready' && photos.length > 0 ? ( + + ) : null} +
+ + {note ?

{note}

: null} + + {state === 'loading' ?

{t('auth.busy')}

: null} + + {state === 'guest' ? ( +

+ {t('adm.signIn')} +

+ ) : null} + + {state === 'forbidden' ?

{t('adm.notAdmin')}

: null} + + {state === 'ready' && photos.length === 0 ?

{t('adm.empty')}

: null} + + {state === 'ready' && photos.length > 0 ? ( + <> +

{t('adm.subtitle')}

+
+ {photos.map((p) => ( +
+ +
+ #{p.id} + {t('adm.owner')}: {p.email} + {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} + {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} +
+ +
+ ))} +
+ + ) : null} +
+ ); +} diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 6e8ca49..e5bcd0f 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api, type Photo, type User } from './api'; import { useI18n } from './i18n/I18nProvider'; import { FONT_SETS, useTheme } from './theme/ThemeProvider'; @@ -109,6 +110,10 @@ export function Landing() { const [con, setCon] = useState(100); const [toast, setToast] = useState(null); const [qrOk, setQrOk] = useState(true); + const [user, setUser] = useState(null); + const [photos, setPhotos] = useState([]); + const [uploading, setUploading] = useState(false); + const filePick = useRef(null); const toastTimer = useRef>(); // Marketing copy, picked by the workspace's language switch. @@ -116,13 +121,53 @@ export function Landing() { const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0]; - const showToast = useCallback((text: Txt) => { - setToast(c(text)); + const showToast = useCallback((text: Txt | string) => { + setToast(typeof text === 'string' ? text : c(text)); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 3400); }, [c]); useEffect(() => () => clearTimeout(toastTimer.current), []); + // The community half of the reel: whoever is signed in gets a picker, and + // everybody sees the result on the next load. A guest is sent to sign up. + useEffect(() => { + let alive = true; + api.me().then((r) => alive && setUser(r.user)).catch(() => undefined); + api.listPhotos().then((r) => alive && setPhotos(r.photos)).catch(() => undefined); + return () => { + alive = false; + }; + }, []); + + const contribute = useCallback(async (file: File) => { + setUploading(true); + try { + const { photo } = await api.uploadPhoto(file); + setPhotos((prev) => [photo, ...prev]); + showToast(t('photo.thanks')); + } catch (err) { + showToast(t('photo.failed', { msg: err instanceof Error ? err.message : String(err) })); + } finally { + setUploading(false); + } + }, [showToast, t]); + + // Contributed stills join the reel after the six built-in looks. Every frame + // is repeated once, so the marquee's -50% translation still loops seamlessly. + const reel = useMemo( + () => [ + ...REEL, + ...photos.map((p) => ({ + tag: '#COMMUNITY', + title: { en: 'Community Frame', vi: 'Khung cộng đồng' }, + meta: `#${p.id}`, + photo: api.photoUrl(p.id), + filter: 'none', + })), + ], + [photos], + ); + // The theme popover closes on a click anywhere else, like the workspace's. useEffect(() => { if (!themeMenu) return; @@ -274,8 +319,41 @@ export function Landing() { {/* 3 — 35mm film strip marquee */}
+
+
+ {c({ en: 'Community film strip', vi: 'Dải phim cộng đồng' })} +

{c({ en: 'Shoot it, grade it, hang it on the reel', vi: 'Chụp, chỉnh màu, rồi treo lên dải phim' })}

+

{c({ en: 'Signed-in members can add a still of their own to the strip below, seen by everyone who lands here.', vi: 'Thành viên đã đăng nhập có thể thêm một khung ảnh của mình vào dải phim bên dưới, ai ghé trang cũng thấy.' })}

+
+ {user ? ( + + ) : ( + /* A guest gets the same button; it opens the sign-up dialog. */ + {t('photo.contribute')} + )} + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) void contribute(file); + }} + /> +
- {[...REEL, ...REEL].map((look, i) => ( + {[...reel, ...reel].map((look, i) => (