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:
2026-09-18 16:57:46 +07:00
parent a35ecf4f1c
commit 8a889db069
11 changed files with 233 additions and 18 deletions
+20
View File
@@ -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