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:
@@ -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' }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user