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