web: the QR card hands out the look that made the photo
A photo's landing section can now be the QR card, and that section is the only one that hands something out: the server writes the photo's own stored look back as the app's .recipe file, at GET /api/photos/:id/preset.recipe, for any row the curator ticked into the qr slot. Nothing new is stored — the file is built from the recipe the upload already carried, so it works for a photo uploaded by the phone too. The admin pane grows a fourth checkbox and a fourth row (QR card); the row draws the download link as a scannable code, and the box is dead for a photo with no stored look. The landing's QR card now encodes the curated photo's own link instead of a mock address. The listing exposes hasPreset, never the recipe itself.
This commit is contained in:
@@ -331,6 +331,57 @@ try {
|
||||
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);
|
||||
|
||||
// ---- preset link --------------------------------------------------------
|
||||
// The look a photo was uploaded with is the landing QR card's payload: the
|
||||
// `.recipe` file the app reads back on IMPORT. The curator's `qr` tick is the
|
||||
// whole permission — nothing else is a link, and the listing exposes only
|
||||
// whether a look exists, never the look.
|
||||
const look = { name: 'QR LOOK', baseFilter: 'velvia', adjustments: { contrast: 7 }, frameId: 'none' };
|
||||
const withLook = await user.req(`/photos?recipe=${encodeURIComponent(JSON.stringify(look))}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'image/png' },
|
||||
body: PNG,
|
||||
});
|
||||
const presetId = withLook.body?.photo?.id;
|
||||
check('an upload that carried a look says so', withLook.body?.photo?.hasPreset === true);
|
||||
check('a photo with no look says no', rows.find((r) => r.id === id)?.hasPreset === false);
|
||||
const listedRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === presetId);
|
||||
check('the public listing counts looks, never ships them', listedRow?.hasPreset === true && !('recipe' in listedRow));
|
||||
check('an un-curated photo is not a link', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
|
||||
check('a lookless photo is not a link either', (await actor().req(`/photos/${id}/preset.recipe`)).status === 404);
|
||||
check('an unknown row is a 404', (await actor().req('/photos/999999/preset.recipe')).status === 404);
|
||||
check('a nonsense id is a 404, not a crash', (await actor().req('/photos/abc/preset.recipe')).status === 404);
|
||||
|
||||
await patch(`/admin/photos/${presetId}`, { slots: ['qr'] });
|
||||
const presetFile = await fetch(`${BASE}/photos/${presetId}/preset.recipe`);
|
||||
const presetXml = await presetFile.text();
|
||||
check(
|
||||
'a curated photo serves its look as a download',
|
||||
presetFile.status === 200 && /^application\/xml/.test(presetFile.headers.get('content-type') ?? ''),
|
||||
`got ${presetFile.status}`,
|
||||
);
|
||||
check(
|
||||
'the download carries the row it came from',
|
||||
presetFile.headers.get('content-disposition') === `attachment; filename="recipescam-${presetId}.recipe"`,
|
||||
presetFile.headers.get('content-disposition') ?? '',
|
||||
);
|
||||
check(
|
||||
'the file is the app’s own envelope',
|
||||
/^<\?xml version="1\.0" encoding="UTF-8"\?>\n<recipescam-recipe version="1" algorithm="xor16-v1" salt="[0-9a-f]+">\n {2}<payload>[0-9a-f]+<\/payload>\n<\/recipescam-recipe>\n$/.test(
|
||||
presetXml,
|
||||
),
|
||||
);
|
||||
const again = await (await fetch(`${BASE}/photos/${presetId}/preset.recipe`)).text();
|
||||
check(
|
||||
'every download gets its own salt',
|
||||
/salt="([0-9a-f]+)"/.exec(again)?.[1] !== /salt="([0-9a-f]+)"/.exec(presetXml)?.[1],
|
||||
);
|
||||
|
||||
// Taking the photo out of the `qr` section takes the link away with it.
|
||||
await patch(`/admin/photos/${presetId}`, { slots: [] });
|
||||
check('an un-curated photo loses its link again', (await actor().req(`/photos/${presetId}/preset.recipe`)).status === 404);
|
||||
await user.req(`/photos/${presetId}`, { method: 'DELETE' });
|
||||
|
||||
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
|
||||
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
|
||||
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404);
|
||||
|
||||
Reference in New Issue
Block a user