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
+20
View File
@@ -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.',
+20
View File
@@ -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.',