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
+66 -7
View File
@@ -104,6 +104,19 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
}
}
// The strip's own labels, added after the first contributions were on disk: the
// tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title
// and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are
// optional and length-capped by the route that accepts them.
{
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
for (const name of ['tag', 'title', 'meta']) {
if (!cols.some((c) => c.name === name)) {
db.exec(`ALTER TABLE photos ADD COLUMN ${name} TEXT`);
}
}
}
// `avatar` is the stored file name, or null for "no picture".
export type User = {
id: number;
@@ -257,13 +270,26 @@ export function deleteRecipe(userId: number, id: number): boolean {
// ---- contributed strip photos -------------------------------------------
// The public shape carries no owner: the landing page is anonymous, so the
// uploader's email must never be reachable from an unauthenticated request.
export type Photo = { id: number; createdAt: string; slot: PhotoSlot };
// `tag`/`title`/`meta` are the frame's own labels (see the migration above).
export type Photo = {
id: number;
createdAt: string;
slot: PhotoSlot;
tag: string | null;
title: string | null;
meta: string | null;
};
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
export type PhotoMeta = { tag?: string | null; title?: string | null; meta?: string | null };
// One SELECT list, so the four call sites cannot drift apart.
const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
photos.tag AS tag, photos.title AS title, photos.meta AS meta`;
export function listPhotos(): Photo[] {
return db
.prepare(
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot
`SELECT ${PHOTO_COLUMNS}
FROM photos JOIN users ON users.id = photos.user_id
WHERE users.deleted_at IS NULL
ORDER BY photos.id DESC`,
@@ -274,7 +300,7 @@ export function listPhotos(): Photo[] {
export function listPhotosWithOwner(): AdminPhoto[] {
return db
.prepare(
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
`SELECT ${PHOTO_COLUMNS},
photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes,
users.email AS email
FROM photos JOIN users ON users.id = photos.user_id
@@ -283,6 +309,13 @@ export function listPhotosWithOwner(): AdminPhoto[] {
.all() as AdminPhoto[];
}
// A member's own folder, newest first. No JOIN: the owner is the caller.
export function listPhotosByUser(userId: number): Photo[] {
return db
.prepare(`SELECT ${PHOTO_COLUMNS} FROM photos WHERE user_id = ? ORDER BY photos.id DESC`)
.all(userId) as Photo[];
}
// Admin listing: one row per account with how many photos it owns. Blocked and
// removed accounts stay listed — a removed one has to be findable to restore it.
export type AdminUser = {
@@ -363,13 +396,28 @@ export function countPhotos(userId: number): number {
return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n;
}
export function createPhoto(userId: number, file: string, mime: string, bytes: number): Photo {
export function createPhoto(
userId: number,
file: string,
mime: string,
bytes: number,
meta?: PhotoMeta,
): Photo {
const ts = now();
const info = db
.prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)')
.run(userId, file, mime, bytes, ts);
.prepare(
'INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
.run(userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null);
// A fresh upload is a strip photo until the curator moves it to a live slot.
return { id: Number(info.lastInsertRowid), createdAt: ts, slot: 'strip' };
return {
id: Number(info.lastInsertRowid),
createdAt: ts,
slot: 'strip',
tag: meta?.tag ?? null,
title: meta?.title ?? null,
meta: meta?.meta ?? null,
};
}
// The stored file name is only ever used through here, and callers must still
@@ -387,6 +435,17 @@ export function deletePhoto(id: number): string | undefined {
return row.file;
}
// The owner's own delete: the user_id in the WHERE is the whole authorisation,
// so a member can never name someone else's row.
export function deletePhotoOf(userId: number, id: number): string | undefined {
const row = db.prepare('SELECT file FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as
| { file: string }
| undefined;
if (!row) return undefined;
db.prepare('DELETE FROM photos WHERE id = ? AND user_id = ?').run(id, userId);
return row.file;
}
// Curating, not moderating: where this photo is allowed to surface.
export function setPhotoSlot(id: number, slot: PhotoSlot): boolean {
return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0;
+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.