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