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