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
+123
View File
@@ -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>
);
}
+82 -4
View File
@@ -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 />
+38 -1
View File
@@ -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' }),
};
+20
View File
@@ -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.',
+20
View File
@@ -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 -4
View File
@@ -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>
);
+24
View File
@@ -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; }
+13
View File
@@ -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;