Files
RecipesCam/docker/frontend/shared/utils/recipeShare.ts
T
3dtours d31d945827 Add the CREATE RECIPES tab to the web app
The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding
from the look on screen and clamping the same way. SAVE RECIPE applies the new
look, lists it under RECIPES and, when signed in, stores it on the account; a
guest's copy stays in memory and goes away with the page. Signed-in users can
also export the recipe as the app's encrypted .recipe file (shared/utils
/recipeShare.ts vendored byte-identical from the RN project).
2026-09-17 18:56:17 +07:00

100 lines
3.9 KiB
TypeScript

import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
// recipe JSON, so a shared recipe imports back into the app on another phone.
//
// ponytail: obfuscation, not cryptography — the key ships inside the app and
// the keystream is a plain xorshift. It stops hand-editing and casual peeking,
// which is all a shared look needs; swap in expo-crypto/AES at the same two
// functions if the format ever has to resist a determined attacker.
const KEY = 'RecipesCam::recipe-share::v1';
const ALGORITHM = 'xor16-v1';
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'];
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;
};
const unscramble = (hex: string, salt: number): string => {
if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.');
const k = stream(hash(`${KEY}|${salt}`));
let out = '';
for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k());
return out;
};
// The shareable half of a recipe: never the local id, and only known fields, so
// a file from a newer version cannot smuggle anything into storage.
export function exportRecipeXml(recipe: Recipe): string {
const shareable = {
name: recipe.name,
baseFilter: recipe.baseFilter,
adjustments: recipe.adjustments,
frameId: recipe.frameId,
useGeotag: recipe.useGeotag,
};
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(JSON.stringify(shareable), salt)}</payload>`,
'</recipescam-recipe>',
'',
].join('\n');
}
// Throws with a user-readable reason. Fields are validated and clamped to what
// this build understands: a recipe from a newer app imports as best it can
// instead of poisoning the store.
export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1];
const payload = /<payload>([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, '');
if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.');
let raw: any;
try {
raw = JSON.parse(unscramble(payload, parseInt(salt, 16)));
} catch {
raw = null;
}
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') {
throw new Error('Recipe file is damaged or from another app.');
}
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
}