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:
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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('<svg xmlns="http://www.w3.org/2000/svg"/>'), 'image/svg+xml')).status === 415,
|
||||
);
|
||||
check(
|
||||
'svg bytes under an image content-type is refused',
|
||||
(await user.upload(Buffer.from('<svg onload="alert(1)"/>'), '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);
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<State>('loading');
|
||||
const [photos, setPhotos] = useState<AdminPhoto[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(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 (
|
||||
<div className="adm">
|
||||
<header className="adm-bar">
|
||||
<a className="btn ghost" href="/">{t('adm.back')}</a>
|
||||
<b className="adm-title">{t('adm.title')}</b>
|
||||
<span className="adm-spacer" />
|
||||
<span className="hint">{t('adm.total', { n: photos.length })}</span>
|
||||
{state === 'ready' && photos.length > 0 ? (
|
||||
<button type="button" className="btn adm-danger" data-key="adm-clear" disabled={busy} onClick={() => void clear()}>
|
||||
{t('adm.clearAll')}
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{note ? <p className="hint adm-note">{note}</p> : null}
|
||||
|
||||
{state === 'loading' ? <p className="hint adm-note">{t('auth.busy')}</p> : null}
|
||||
|
||||
{state === 'guest' ? (
|
||||
<p className="adm-note">
|
||||
<a className="btn primary" href="/app?auth=1">{t('adm.signIn')}</a>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{state === 'forbidden' ? <p className="err adm-note">{t('adm.notAdmin')}</p> : null}
|
||||
|
||||
{state === 'ready' && photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
|
||||
|
||||
{state === 'ready' && photos.length > 0 ? (
|
||||
<>
|
||||
<p className="hint adm-sub">{t('adm.subtitle')}</p>
|
||||
<div className="adm-grid">
|
||||
{photos.map((p) => (
|
||||
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<div className="adm-meta">
|
||||
<b>#{p.id}</b>
|
||||
<span>{t('adm.owner')}: {p.email}</span>
|
||||
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
||||
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`adm-del-${p.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(p.id)}
|
||||
>
|
||||
{t('adm.delete')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [qrOk, setQrOk] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const filePick = useRef<HTMLInputElement>(null);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// 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<Look[]>(
|
||||
() => [
|
||||
...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 */}
|
||||
<section className="lp-marquee" id="film" aria-label={c({ en: 'Film strip gallery', vi: 'Dải phim' })}>
|
||||
<div className="lp-wrap lp-film-head">
|
||||
<div>
|
||||
<span className="lp-eyebrow">{c({ en: 'Community film strip', vi: 'Dải phim cộng đồng' })}</span>
|
||||
<h2>{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' })}</h2>
|
||||
<p>{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.' })}</p>
|
||||
</div>
|
||||
{user ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lp-btn lp-primary"
|
||||
data-key="contribute"
|
||||
disabled={uploading}
|
||||
onClick={() => filePick.current?.click()}
|
||||
>
|
||||
{uploading ? t('photo.uploading') : t('photo.contribute')}
|
||||
</button>
|
||||
) : (
|
||||
/* A guest gets the same button; it opens the sign-up dialog. */
|
||||
<a className="lp-btn lp-primary" data-key="contribute" href="/app?auth=1">{t('photo.contribute')}</a>
|
||||
)}
|
||||
<input
|
||||
ref={filePick}
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
data-key="contribute-input"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) void contribute(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lp-track">
|
||||
{[...REEL, ...REEL].map((look, i) => (
|
||||
{[...reel, ...reel].map((look, i) => (
|
||||
<article className="lp-frame" key={`${look.tag}-${i}`}>
|
||||
<div className="lp-perf" aria-hidden="true">
|
||||
<i /><i /><i /><i /><i /><i />
|
||||
|
||||
@@ -15,6 +15,21 @@ export interface SavedRecipe {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// A strip contribution as the public sees it — the API never puts an email on
|
||||
// this shape.
|
||||
export interface Photo {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Admin listing only: adds the owner, which /api/admin/photos is gated on.
|
||||
export interface AdminPhoto extends Photo {
|
||||
userId: number;
|
||||
email: string;
|
||||
mime: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
@@ -29,7 +44,8 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => call<{ user: User }>('/auth/me'),
|
||||
// null user = signed out; the API answers 200 either way.
|
||||
me: () => call<{ user: User | null }>('/auth/me'),
|
||||
signup: (email: string, password: string) =>
|
||||
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
login: (email: string, password: string) =>
|
||||
@@ -42,4 +58,25 @@ export const api = {
|
||||
updateRecipe: (id: number, name: string, recipe: Recipe) =>
|
||||
call<{ recipe: SavedRecipe }>(`/recipes/${id}`, { method: 'PUT', body: JSON.stringify({ name, recipe }) }),
|
||||
deleteRecipe: (id: number) => call<void>(`/recipes/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// The strip. Upload is the raw file as the request body — one image per
|
||||
// request, so no multipart framing and no extra dependency.
|
||||
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
|
||||
uploadPhoto: async (file: File) => {
|
||||
const res = await fetch('/api/photos', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': file.type },
|
||||
body: file,
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as { photo: Photo };
|
||||
},
|
||||
photoUrl: (id: number) => `/api/photos/${id}/file`,
|
||||
|
||||
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
|
||||
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
|
||||
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -107,6 +107,26 @@ export const en: Dict = {
|
||||
'save.saved': 'Saved "{name}"',
|
||||
'save.needLogin': 'Log in to save a recipe.',
|
||||
|
||||
'photo.contribute': 'CONTRIBUTE A PHOTO',
|
||||
'photo.uploading': 'UPLOADING…',
|
||||
'photo.thanks': 'Your frame is in the strip.',
|
||||
'photo.failed': 'Upload failed: {msg}',
|
||||
|
||||
'adm.title': 'Strip moderation',
|
||||
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
|
||||
'adm.signIn': 'Sign in with an admin account',
|
||||
'adm.notAdmin': 'This account has no admin rights.',
|
||||
'adm.empty': 'No contributions yet.',
|
||||
'adm.total': '{n} photos',
|
||||
'adm.owner': 'Uploaded by',
|
||||
'adm.size': 'Size',
|
||||
'adm.uploaded': 'Uploaded',
|
||||
'adm.delete': 'DELETE',
|
||||
'adm.clearAll': 'DELETE ALL',
|
||||
'adm.clearConfirm': 'Delete all {n} contributions? This cannot be undone.',
|
||||
'adm.removed': 'Deleted {n} photos.',
|
||||
'adm.back': 'Back to home',
|
||||
|
||||
'err.noWebgl':
|
||||
'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).',
|
||||
'err.decode': 'Could not read this image. Try another JPG or PNG.',
|
||||
|
||||
@@ -110,6 +110,26 @@ export const vi = {
|
||||
'save.saved': 'Đã lưu "{name}"',
|
||||
'save.needLogin': 'Đăng nhập để lưu công thức.',
|
||||
|
||||
'photo.contribute': 'ĐÓNG GÓP ẢNH',
|
||||
'photo.uploading': 'ĐANG TẢI…',
|
||||
'photo.thanks': 'Ảnh của bạn đã vào dải phim.',
|
||||
'photo.failed': 'Không tải được ảnh: {msg}',
|
||||
|
||||
'adm.title': 'Quản trị dải phim',
|
||||
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
||||
'adm.signIn': 'Đăng nhập bằng tài khoản quản trị',
|
||||
'adm.notAdmin': 'Tài khoản này không có quyền quản trị.',
|
||||
'adm.empty': 'Chưa có ảnh đóng góp nào.',
|
||||
'adm.total': '{n} ảnh',
|
||||
'adm.owner': 'Người đăng',
|
||||
'adm.size': 'Dung lượng',
|
||||
'adm.uploaded': 'Thời điểm',
|
||||
'adm.delete': 'XOÁ',
|
||||
'adm.clearAll': 'XOÁ TẤT CẢ',
|
||||
'adm.clearConfirm': 'Xoá toàn bộ {n} ảnh đóng góp? Không hoàn tác được.',
|
||||
'adm.removed': 'Đã xoá {n} ảnh.',
|
||||
'adm.back': 'Về trang chủ',
|
||||
|
||||
'err.noWebgl':
|
||||
'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).',
|
||||
'err.decode': 'Không đọc được ảnh này. Thử JPG hoặc PNG khác.',
|
||||
|
||||
@@ -7,10 +7,13 @@ import { ThemeProvider } from './theme/ThemeProvider';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { Landing } from './Landing';
|
||||
import { Workspace } from './App';
|
||||
import { Admin } from './Admin';
|
||||
|
||||
// Two routes, no router: the landing page and the workspace. nginx serves
|
||||
// index.html for both (SPA fallback), so this is just a pathname check.
|
||||
const isWorkspace = window.location.pathname.startsWith('/app');
|
||||
// Three routes, no router: the landing page, the workspace and the strip
|
||||
// moderation screen. nginx serves index.html for all of them (SPA fallback),
|
||||
// so this is just a pathname check.
|
||||
const path = window.location.pathname;
|
||||
const page = path.startsWith('/app') ? <Workspace /> : path.startsWith('/admin') ? <Admin /> : <Landing />;
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('#root missing');
|
||||
|
||||
@@ -18,6 +21,6 @@ if (!root) throw new Error('#root missing');
|
||||
// run the render pipeline twice on every mount.
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>{isWorkspace ? <Workspace /> : <Landing />}</I18nProvider>
|
||||
<I18nProvider>{page}</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -255,6 +255,30 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
}
|
||||
.modal h2 { margin: 0; font-size: 17px; }
|
||||
|
||||
/* --- strip moderation (/admin) ------------------------------------------ */
|
||||
.adm { height: 100%; overflow-y: auto; padding: 18px 22px 40px; }
|
||||
.adm-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.adm-title { font-size: 15px; letter-spacing: 0.02em; }
|
||||
.adm-spacer { flex: 1 1 auto; }
|
||||
.adm-note { margin: 12px 0; }
|
||||
.adm-sub { margin: 0 0 14px; }
|
||||
.adm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.adm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elev);
|
||||
padding: 10px;
|
||||
}
|
||||
.adm-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||||
.adm-meta { display: flex; flex-direction: column; gap: 2px; font-size: 11.5px; color: var(--text-dim); min-width: 0; }
|
||||
.adm-meta b { color: var(--text); font-family: var(--mono); }
|
||||
.adm-meta span { overflow-wrap: anywhere; }
|
||||
.adm-danger { color: var(--danger); border-color: var(--border); }
|
||||
.adm-danger:hover:not(:disabled) { border-color: var(--danger); }
|
||||
|
||||
/* --- responsive --------------------------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
.workspace { flex-direction: column; }
|
||||
|
||||
@@ -227,6 +227,19 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
|
||||
.lp-marquee:hover .lp-track { animation-play-state: paused; }
|
||||
@keyframes lp-marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .lp-track { animation: none; } }
|
||||
/* Contribute control above the reel. The strip itself stays centred on the
|
||||
viewport, so this head is the only wrapped part of the marquee section. */
|
||||
.lp-film-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.lp-film-head > div { max-width: 620px; }
|
||||
.lp-film-head h2 { font-size: clamp(20px, 3vw, 30px); margin-top: 8px; }
|
||||
.lp-film-head p { color: var(--lp-muted); margin-top: 8px; font-size: 13.5px; }
|
||||
.lp-frame {
|
||||
width: 236px;
|
||||
margin-right: 18px;
|
||||
|
||||
Reference in New Issue
Block a user