8a889db069
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.
59 lines
2.2 KiB
TypeScript
59 lines
2.2 KiB
TypeScript
// 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');
|
|
}
|