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
+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 />