feat(photos): community film strip uploads + admin moderation
Backend
- photos table + upload storage under DATA_DIR/uploads (magic-byte sniffing,
no multipart dep, SVG rejected, wx exclusive writes)
- POST/GET /api/photos, GET /api/photos/:id/file with nosniff + sandboxed CSP
- admin routes (ADMIN_EMAILS allowlist): list, delete one, clear all
- identity-keyed rate limits (login 20/15m, signup 5/h, upload 60/h)
- cookie gains Secure when the request is https (via trustProxy)
- /api/auth/me now 200 {user:null} instead of 401 when signed out
Frontend
- landing strip section: signed-in users upload straight from the reel,
guests get a /app?auth=1 link
- /admin page: grid of uploads with delete + clear all
- nginx: nosniff / X-Frame-Options / Referrer-Policy, forward
X-Forwarded-Proto so the API can mark cookies Secure behind TLS
Tests: docker/backend test/security.mjs (45 checks)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, { n: number; until: number }>();
|
||||
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<ImageMime, string> = { '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) => {
|
||||
|
||||
Reference in New Issue
Block a user