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>
);
}