diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 1b3c801..5fe3df4 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -83,21 +83,30 @@ CREATE INDEX IF NOT EXISTS idx_events_at ON events(at); // Where a curated photo is allowed to appear on the landing page: the community // strip, the live tester's preview, the creator lab's preview, or the QR card. -// One is picked at random out of its slot on every page load. `off` is the -// curator's "take it off the landing, keep the row" — the reel and the three -// live slots all draw by exact slot, so an `off` photo shows up nowhere, while -// its owner still has it in MY PHOTOS. -export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr', 'off'] as const; +// A photo may sit in several at once — each section draws its own random pick +// out of its set on every page load. Sitting in none of them is the curator's +// "take it off the landing, keep the row": the owner still has it in MY PHOTOS. +export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const; export type PhotoSlot = (typeof PHOTO_SLOTS)[number]; export const isPhotoSlot = (v: unknown): v is PhotoSlot => typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v); +// The column holds the set as a comma list. Ids it does not know — the retired +// single-slot `off`, anything hand-edited — are dropped, so a row can always be +// read back as a set the landing page understands. +export const parseSlots = (raw: string | null | undefined): PhotoSlot[] => + (raw ?? '').split(',').filter(isPhotoSlot); +export const serializeSlots = (slots: readonly PhotoSlot[]): string => + [...new Set(slots.filter(isPhotoSlot))].join(','); // The column arrived after the first strips were already on disk, so add it in // place — `CREATE TABLE IF NOT EXISTS` would silently skip an existing table. +// It first held one slot; the rename keeps every existing row readable, since +// a bare `strip` parses as a one-member set and the old `off` as the empty one. { const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; - if (!cols.some((c) => c.name === 'slot')) { - db.exec(`ALTER TABLE photos ADD COLUMN slot TEXT NOT NULL DEFAULT 'strip'`); + if (!cols.some((c) => c.name === 'slots')) { + if (cols.some((c) => c.name === 'slot')) db.exec(`ALTER TABLE photos RENAME COLUMN slot TO slots`); + else db.exec(`ALTER TABLE photos ADD COLUMN slots TEXT NOT NULL DEFAULT 'strip'`); } } @@ -320,7 +329,9 @@ export function deleteRecipe(userId: number, id: number): boolean { export type Photo = { id: number; createdAt: string; - slot: PhotoSlot; + // Every landing section this photo is allowed to appear in; empty means it + // is curated off the landing page entirely. + slots: PhotoSlot[]; tag: string | null; title: string | null; meta: string | null; @@ -341,14 +352,19 @@ export type PhotoMeta = { }; // One SELECT list, so the call sites cannot drift apart. -const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot, +const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slots AS slots, photos.tag AS tag, photos.title AS title, photos.meta AS meta, photos.consent AS consent`; // SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text. -type PhotoRow = Omit & { consent: number }; +// `slots` comes back as the stored comma list, turned into a set on the way out. +type PhotoRow = Omit & { consent: number; slots: string | null }; type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null }; -const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 }); +const toPhoto = (row: PhotoRow): Photo => ({ + ...row, + consent: row.consent === 1, + slots: parseSlots(row.slots), +}); // A row whose JSON will not parse is still a photo: its settings are simply // gone, not worth failing the whole folder over. Same for one bad entry in the // history — the rest of the list still stands. @@ -513,11 +529,12 @@ export function createPhoto( meta?.consent === false ? 0 : 1, meta?.recipe === undefined ? null : JSON.stringify(meta.recipe), ); - // A fresh upload is a strip photo until the curator moves it to a live slot. + // A fresh upload starts in the community strip; the curator may put it in + // any of the live sections too, or take it off the landing. return { id: Number(info.lastInsertRowid), createdAt: ts, - slot: 'strip', + slots: ['strip'], tag: meta?.tag ?? null, title: meta?.title ?? null, meta: meta?.meta ?? null, @@ -610,9 +627,10 @@ export function deletePhotoOf(userId: number, id: number): string | undefined { return row.file; } -// Curating, not moderating: where this photo is allowed to surface. -export function setPhotoSlot(id: number, slot: PhotoSlot): boolean { - return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0; +// Curating, not moderating: where this photo is allowed to surface. The whole +// set arrives at once — the admin's checkboxes are the only writer. +export function setPhotoSlots(id: number, slots: readonly PhotoSlot[]): boolean { + return db.prepare('UPDATE photos SET slots = ? WHERE id = ?').run(serializeSlots(slots), id).changes > 0; } export function deleteAllPhotos(): string[] { diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 63e2bcb..254a12c 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -35,7 +35,7 @@ import { photoFile, photoPath, sessionUser, - setPhotoSlot, + setPhotoSlots, setPhotoConsent, setUserAvatar, setUserBlocked, @@ -49,6 +49,7 @@ import { type Recipe, type User, type EventKind, + type PhotoSlot, } from './db'; const PORT = Number(process.env.PORT || 3000); @@ -804,18 +805,22 @@ app.delete('/api/admin/photos', async (req, reply) => { return reply.status(200).send({ removed: files.length }); }); -// Curating: which slot on the landing page this photo is allowed to appear in. -// The landing page picks one at random per slot, so several photos in one slot -// rotate between visits. +// Curating: the landing sections this photo is allowed to appear in. A photo +// may sit in several at once — each section picks one of its own at random per +// visit, so several photos in one section rotate. An empty set takes it off +// the landing without deleting the row. app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); const b = bodyOf(req); - if (!b || !isPhotoSlot(b.slot)) return reply.status(400).send({ error: 'invalid slot' }); - if (!setPhotoSlot(id, b.slot)) return reply.status(404).send({ error: 'photo not found' }); - return reply.status(200).send({ id, slot: b.slot }); + const slots = b?.slots; + if (!Array.isArray(slots) || !slots.every((s) => isPhotoSlot(s))) + return reply.status(400).send({ error: 'invalid slots' }); + const set: PhotoSlot[] = [...new Set(slots as PhotoSlot[])]; + if (!setPhotoSlots(id, set)) return reply.status(404).send({ error: 'photo not found' }); + return reply.status(200).send({ id, slots: set }); }); app diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index 4b8ad0d..a721f41 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -284,7 +284,10 @@ try { const rows = adminList.body?.photos ?? []; check('an admin lists contributions', adminList.status === 200 && rows.length > 0); check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? ''))); - check('a fresh upload lands in the strip slot', rows.find((r) => r.id === id)?.slot === 'strip'); + check( + 'a fresh upload lands in the strip section', + JSON.stringify(rows.find((r) => r.id === id)?.slots) === JSON.stringify(['strip']), + ); check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403); // ---- placement ---------------------------------------------------------- @@ -295,27 +298,38 @@ try { (await user.req(`/admin/photos/${id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ slot: 'qr' }), + body: JSON.stringify({ slots: ['qr'] }), })).status === 403, ); - const placed = await patch(`/admin/photos/${id}`, { slot: 'qr' }); - check('an admin moves a photo to a live slot', placed.status === 200 && placed.body?.slot === 'qr', JSON.stringify(placed.body)); + const placed = await patch(`/admin/photos/${id}`, { slots: ['tester', 'creator'] }); check( - 'the slot is public, the owner is not', - ((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'qr', + 'an admin puts a photo in several sections at once', + placed.status === 200 && JSON.stringify(placed.body?.slots) === JSON.stringify(['tester', 'creator']), + JSON.stringify(placed.body), ); - // `off` is the curator's removal: the reel draws slot === 'strip' and the - // live slots draw their own, so the row shows up nowhere — but the uploader - // keeps it in their folder. - const off = await patch(`/admin/photos/${id}`, { slot: 'off' }); - check('an admin takes a photo off the landing', off.status === 200 && off.body?.slot === 'off', JSON.stringify(off.body)); check( - 'an off photo is on no landing slot', - ((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'off', + 'the sections are public, the owner is not', + JSON.stringify(((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots) === + JSON.stringify(['tester', 'creator']), + ); + const one = await patch(`/admin/photos/${id}`, { slots: ['strip', 'strip', 'qr'] }); + check( + 'a repeated section is stored once', + JSON.stringify(one.body?.slots) === JSON.stringify(['strip', 'qr']), + JSON.stringify(one.body), + ); + // An empty set is the curator's removal: no landing section draws the row, + // but the uploader keeps it in their folder. + const off = await patch(`/admin/photos/${id}`, { slots: [] }); + check('an admin takes a photo off the landing', off.status === 200 && off.body?.slots?.length === 0, JSON.stringify(off.body)); + check( + 'an off photo is on no landing section', + ((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slots?.length === 0, ); check('its owner still has it in the folder', ((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === id)); - check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slot: 'nope' })).status === 400); - check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slot: 'qr' })).status === 404); + check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slots: ['nope'] })).status === 400); + check('a bare slot string is refused', (await patch(`/admin/photos/${id}`, { slots: 'strip' })).status === 400); + check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slots: ['qr'] })).status === 404); const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' }); check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`); diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 230a98e..fa2c560 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -19,11 +19,10 @@ import type { MsgKey } from './i18n/vi'; type State = 'loading' | 'guest' | 'forbidden' | 'ready'; type Tab = 'profile' | 'users' | 'pictures' | 'stats'; -// Where a photo can be put. One destination each — the landing page draws the -// film strip from `strip` and one random photo per visit out of each live set. -// Clicking the destination a photo is already in takes it off the landing -// without deleting the uploader's row (slot `off`), which is the same switch -// the old picker carried. +// Where a photo can be put. The boxes are independent — a photo may sit in all +// three sections at once, and each section draws one random photo per visit out +// of its own set. Unticking every box takes the photo off the landing without +// deleting the uploader's row, which is the same switch the old picker carried. const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [ { id: 'strip', key: 'adm.pickStrip' }, { id: 'tester', key: 'adm.pickTester' }, @@ -102,10 +101,21 @@ export function Admin() { await refreshPhotos(); }); - const setSlot = (id: number, slot: PhotoSlot) => + // One checkbox tick: the whole new set goes to the API in one call. The box + // flips under the curator's finger first — waiting for the round trip would + // snap it back for a frame and read as a lost click — and a failed write puts + // the old set back. + const setSlots = (id: number, next: PhotoSlot[]) => run(async () => { - await api.adminSetPhotoSlot(id, slot); - setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slot } : p))); + const before = photos.find((p) => p.id === id)?.slots ?? []; + const show = (slots: PhotoSlot[]) => setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slots } : p))); + show(next); + try { + await api.adminSetPhotoSlots(id, next); + } catch (err) { + show(before); + throw err; + } }); const clear = () => { @@ -263,21 +273,28 @@ export function Admin() { {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} - {/* One button per destination. The one the photo is - already in reads pressed; pressing it sets `off`. */} + {/* One checkbox per destination, all three on one + line: tick as many as the photo should appear in, + untick the last one to take it off the landing. */}
{DESTINATIONS.map((d) => ( - + ))}