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
+7 -4
View File
@@ -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>
);