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