web: keep a saved photo's look in EXIF and its own row

EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.

SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
This commit is contained in:
2026-09-18 12:49:05 +07:00
parent cf4b01d4b3
commit 0627f8dd91
9 changed files with 292 additions and 39 deletions
+38
View File
@@ -33,6 +33,7 @@ import {
photoPath,
sessionUser,
setPhotoSlot,
setPhotoConsent,
setUserAvatar,
setUserBlocked,
setUserPassword,
@@ -339,6 +340,9 @@ app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) =>
// there is no JSON envelope to put them in. Capped and control-stripped here,
// because they are drawn and stored rather than trusted.
const META_MAX = { tag: 64, title: 120, meta: 160 } as const;
// The recipe travels as one URL-encoded query parameter beside the labels; this
// is the ceiling of what the studio can hand back (a real one is well under 1KB).
const RECIPE_PARAM_MAX = 3000;
function cleanMeta(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
@@ -353,9 +357,28 @@ function photoMeta(req: FastifyRequest): PhotoMeta {
tag: cleanMeta(q.tag, META_MAX.tag),
title: cleanMeta(q.title, META_MAX.title),
meta: cleanMeta(q.meta, META_MAX.meta),
// The look that made the pixels, so the studio can open the photo again.
// It rides the query string like the labels do (the body is the raw image),
// so it is capped here: a recipe this app writes is well under a kilobyte.
recipe: parseRecipeParam(q.recipe),
// Consent is the uploader's, and only an explicit false opts out.
consent: q.consent !== '0',
};
}
// Anything the studio could not read back as a recipe object is dropped, never
// stored half-parsed: the row must not carry a blob that breaks the folder.
function parseRecipeParam(raw: unknown): unknown {
if (typeof raw !== 'string' || raw.length === 0) return undefined;
if (raw.length > RECIPE_PARAM_MAX) return undefined;
try {
const value: unknown = JSON.parse(raw);
return value && typeof value === 'object' ? value : undefined;
} catch {
return undefined;
}
}
// Anyone may read the strip; only a signed-in account may add to it. The bytes
// are written under a server-generated name, so a caller's own filename never
// reaches the filesystem, and the row is the only place the real mime lives.
@@ -460,6 +483,21 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) =
.send(data);
});
// 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
// the authorisation.
app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const body = (req.body ?? {}) as { consent?: unknown };
if (typeof body.consent !== 'boolean') return reply.status(400).send({ error: 'invalid consent' });
if (!setPhotoConsent(user.id, id, body.consent)) return reply.status(404).send({ error: 'photo not found' });
return reply.status(200).send({ id, consent: body.consent });
});
// Removing one of your own photos. An admin may remove anyone's from here too,
// so the folder and the moderation screen share one route. The row is only
// dropped when the caller owns it (or curates the whole strip), and the file