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:
2026-09-17 22:35:12 +07:00
parent 9ba2667c6c
commit ffdefd2c9c
15 changed files with 871 additions and 18 deletions
+75
View File
@@ -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;
}