web: give each member a photo folder and burn the strip into the export

Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
This commit is contained in:
2026-09-18 10:56:26 +07:00
parent 43d86b4b6f
commit b4d5d2926b
14 changed files with 576 additions and 22 deletions
+48 -1
View File
@@ -16,6 +16,7 @@ import {
createUser,
deleteAllPhotos,
deletePhoto,
deletePhotoOf,
deleteRecipe,
deleteSession,
deleteUser,
@@ -23,6 +24,7 @@ import {
findUserById,
isPhotoSlot,
listPhotos,
listPhotosByUser,
listPhotosWithOwner,
listRecipes,
listUsersWithCounts,
@@ -39,6 +41,7 @@ import {
updateUserEmail,
userAvatar,
verifyPassword,
type PhotoMeta,
type Recipe,
type User,
} from './db';
@@ -332,11 +335,40 @@ app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) =>
});
// ---- contributed strip photos -------------------------------------------
// The frame's own labels ride the query string: the body is the raw image, so
// 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;
function cleanMeta(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
// eslint-disable-next-line no-control-regex
const text = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max);
return text || null;
}
function photoMeta(req: FastifyRequest): PhotoMeta {
const q = (req.query ?? {}) as Record<string, unknown>;
return {
tag: cleanMeta(q.tag, META_MAX.tag),
title: cleanMeta(q.title, META_MAX.title),
meta: cleanMeta(q.meta, META_MAX.meta),
};
}
// 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.
app.get('/api/photos', async () => ({ photos: listPhotos() }));
// The caller's own folder — the count the studio's SAVE PHOTO shows comes from
// here, and the admin drill-down reads the same rows through /admin/photos.
app.get('/api/photos/mine', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
@@ -356,7 +388,7 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
writeFileSync(photoPath(file), body, { flag: 'wx' });
const photo = createPhoto(user.id, file, mime, body.length);
const photo = createPhoto(user.id, file, mime, body.length, photoMeta(req));
return reply.status(201).send({ photo });
});
@@ -428,6 +460,21 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) =
.send(data);
});
// 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
// goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink.
app.delete<{ 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 file = isAdmin(user) ? deletePhoto(id) : deletePhotoOf(user.id, id);
if (!file) return reply.status(404).send({ error: 'photo not found' });
unlink(file);
return reply.status(204).send();
});
// ---- admin ---------------------------------------------------------------
// Moderation only: the allowlist can list everything and clean up. There is
// deliberately no endpoint here that grants the privilege itself.