web: give each member a photo folder and burn the strip into the export

Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
This commit is contained in:
2026-09-18 10:56:26 +07:00
parent 43d86b4b6f
commit b4d5d2926b
14 changed files with 576 additions and 22 deletions
+57
View File
@@ -218,6 +218,63 @@ try {
(await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404,
);
// ---- the member's own folder --------------------------------------------
check('a guest has no folder', (await actor().req('/photos/mine')).status === 401);
const folder = await user.req('/photos/mine');
check(
'a member lists their own photos',
folder.status === 200 && (folder.body?.photos ?? []).some((p) => p.id === id),
JSON.stringify(folder.body).slice(0, 120),
);
const stranger = actor();
await stranger.signup(`stranger${stamp}@test.local`);
check("a fresh account's folder is empty", ((await stranger.req('/photos/mine')).body?.photos ?? []).length === 0);
// The strip's own labels ride the query string: the body is the image.
const labels = { tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18' };
const labelled = await user.req(
`/photos?tag=${encodeURIComponent(labels.tag)}&title=${encodeURIComponent(labels.title)}&meta=${encodeURIComponent(labels.meta)}`,
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
);
check(
'an upload carries its strip labels',
labelled.status === 201 &&
labelled.body?.photo?.tag === labels.tag &&
labelled.body?.photo?.title === labels.title &&
labelled.body?.photo?.meta === labels.meta,
JSON.stringify(labelled.body),
);
const labelledId = labelled.body?.photo?.id;
const publicRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === labelledId);
check('the labels reach the public strip', publicRow?.tag === labels.tag && publicRow?.meta === labels.meta);
const capped = await user.req(
`/photos?tag=${'x'.repeat(200)}&meta=${encodeURIComponent('bad\u0007line')}`,
{ method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG },
);
check('a label is length-capped', capped.body?.photo?.tag?.length === 64, `len ${capped.body?.photo?.tag?.length}`);
check('a label is control-stripped', capped.body?.photo?.meta === 'bad line', JSON.stringify(capped.body?.photo?.meta));
check(
'a member cannot delete a photo they do not own',
(await stranger.req(`/photos/${labelledId}`, { method: 'DELETE' })).status === 404,
);
check('the stranger’s delete leaves the file alone', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 200);
const ownDelete = await user.req(`/photos/${labelledId}`, { method: 'DELETE' });
check('a member deletes their own photo', ownDelete.status === 204, `got ${ownDelete.status}`);
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 404);
check(
'the row leaves the folder',
!((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === labelledId),
);
// The curator removes anyone's through the same route — the moderation screen
// keeps its own two admin endpoints, this one just shares the job.
const spare = (await user.upload(PNG, 'image/png')).body?.photo;
check('an admin deletes through the member route too', (await admin.req(`/photos/${spare?.id}`, { method: 'DELETE' })).status === 204);
check('a deleted photo leaves the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === spare?.id));
// ---- moderation ---------------------------------------------------------
check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401);
const forbidden = await user.req('/admin/photos');