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:
@@ -338,6 +338,10 @@ export type Photo = {
|
||||
// The uploader's permission for this photo to appear on the landing strip.
|
||||
// The owner's own folder reads it back to draw the toggle.
|
||||
consent: boolean;
|
||||
// Whether the row still carries the look that made it — the only thing the
|
||||
// QR card can hand out (see the preset route in server.ts). A bool, not the
|
||||
// recipe itself: the public listing has no business shipping looks.
|
||||
hasPreset: boolean;
|
||||
};
|
||||
// The owner's own row adds the look that made it, so it can be opened again,
|
||||
// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX.
|
||||
@@ -354,15 +358,20 @@ 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.slots AS slots,
|
||||
photos.tag AS tag, photos.title AS title, photos.meta AS meta,
|
||||
photos.consent AS consent`;
|
||||
photos.consent AS consent, (photos.recipe IS NOT NULL) AS hasPreset`;
|
||||
|
||||
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
|
||||
// `slots` comes back as the stored comma list, turned into a set on the way out.
|
||||
type PhotoRow = Omit<Photo, 'consent' | 'slots'> & { consent: number; slots: string | null };
|
||||
type PhotoRow = Omit<Photo, 'consent' | 'slots' | 'hasPreset'> & {
|
||||
consent: number;
|
||||
slots: string | null;
|
||||
hasPreset: number;
|
||||
};
|
||||
type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
|
||||
const toPhoto = (row: PhotoRow): Photo => ({
|
||||
...row,
|
||||
consent: row.consent === 1,
|
||||
hasPreset: row.hasPreset === 1,
|
||||
slots: parseSlots(row.slots),
|
||||
});
|
||||
// A row whose JSON will not parse is still a photo: its settings are simply
|
||||
@@ -539,6 +548,7 @@ export function createPhoto(
|
||||
title: meta?.title ?? null,
|
||||
meta: meta?.meta ?? null,
|
||||
consent: meta?.consent !== false,
|
||||
hasPreset: meta?.recipe !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -609,6 +619,16 @@ export function photoFile(id: number): { file: string; mime: string } | undefine
|
||||
| undefined;
|
||||
}
|
||||
|
||||
// The QR card's payload: the stored look, as raw JSON, and where the photo is
|
||||
// allowed to show. The route decides who may read it (a curated `qr` slot), so
|
||||
// this returns the row as stored, recipe included.
|
||||
export function photoPreset(id: number): { recipe: string | null; slots: PhotoSlot[] } | undefined {
|
||||
const row = db.prepare('SELECT recipe, slots FROM photos WHERE id = ?').get(id) as
|
||||
| { recipe: string | null; slots: string | null }
|
||||
| undefined;
|
||||
return row && { recipe: row.recipe, slots: parseSlots(row.slots) };
|
||||
}
|
||||
|
||||
export function deletePhoto(id: number): string | undefined {
|
||||
const row = db.prepare('SELECT file FROM photos WHERE id = ?').get(id) as { file: string } | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// The `.recipe` file the app writes on EXPORT and reads back on IMPORT. It is a
|
||||
// small XML envelope around a scrambled hex payload, and the landing page's QR
|
||||
// card hands a photo's look back as exactly this file, so the server has to
|
||||
// write the app's own format. The source of truth is the web app's
|
||||
// `shared/utils/recipeShare.ts`; this module and the phone app's
|
||||
// `src/utils/recipeShare.ts` are its copies, and `test/security.mjs` pins the
|
||||
// envelope's shape. Change all three together, or the QR stops importing.
|
||||
//
|
||||
// ponytail: obfuscation, not cryptography — the key ships inside the app. Same
|
||||
// caveat as the app's copy: swap in a real cipher at these two functions if the
|
||||
// envelope ever has to resist a determined reader.
|
||||
const KEY = 'RecipesCam::recipe-share::v1';
|
||||
const ALGORITHM = 'xor16-v1';
|
||||
|
||||
const hash = (s: string): number => {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
// xorshift32: one 16-bit word of keystream per call.
|
||||
const stream = (seed: number) => {
|
||||
let s = seed >>> 0 || 0x9e3779b9;
|
||||
return () => {
|
||||
s ^= s << 13;
|
||||
s >>>= 0;
|
||||
s ^= s >>> 17;
|
||||
s ^= s << 5;
|
||||
s >>>= 0;
|
||||
return s & 0xffff;
|
||||
};
|
||||
};
|
||||
|
||||
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
|
||||
// with Vietnamese diacritics included) survives the round trip.
|
||||
const scramble = (text: string, salt: number): string => {
|
||||
const k = stream(hash(`${KEY}|${salt}`));
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
|
||||
return out;
|
||||
};
|
||||
|
||||
// The recipe as the app stored it, wrapped in the envelope. The payload is the
|
||||
// stored JSON whole: the parser on the other side keeps the fields it knows and
|
||||
// drops the rest, so this side needs no opinion about what a recipe contains.
|
||||
export function recipeFile(recipeJson: string): string {
|
||||
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
|
||||
` <payload>${scramble(recipeJson, salt)}</payload>`,
|
||||
'</recipescam-recipe>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { recipeFile } from './recipeFile';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
avatarPath,
|
||||
photoFile,
|
||||
photoPath,
|
||||
photoPreset,
|
||||
sessionUser,
|
||||
setPhotoSlots,
|
||||
setPhotoConsent,
|
||||
@@ -644,6 +646,24 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) =
|
||||
.send(data);
|
||||
});
|
||||
|
||||
// The QR card's payload: the `.recipe` file the app reads back on IMPORT, built
|
||||
// from the look the photo was uploaded with. Public like the strip, but only
|
||||
// for a row the curator ticked into the `qr` section — that checkbox is the
|
||||
// whole permission. Everything else is a 404 rather than a 403, so the route
|
||||
// cannot be used to probe which photos carry a look.
|
||||
app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req, reply) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
|
||||
const row = photoPreset(id);
|
||||
if (!row || row.recipe === null || !row.slots.includes('qr')) return reply.status(404).send({ error: 'not_found' });
|
||||
return reply
|
||||
.header('content-type', 'application/xml; charset=utf-8')
|
||||
.header('x-content-type-options', 'nosniff')
|
||||
.header('content-disposition', `attachment; filename="recipescam-${id}.recipe"`)
|
||||
.header('cache-control', 'public, max-age=60')
|
||||
.send(recipeFile(row.recipe));
|
||||
});
|
||||
|
||||
// The uploader's own permission switch: may this photo show on the landing
|
||||
// strip? Only the owner may flip it (an admin curates the slot, not the
|
||||
// consent), and only their own row is reachable — the user_id in the WHERE is
|
||||
|
||||
@@ -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