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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user