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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user