Photo slots + admin page: place any upload in the strip or a live slot, sign up in place, brand links home
This commit is contained in:
@@ -56,6 +56,23 @@ CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
|
||||
`);
|
||||
|
||||
// Where a curated photo is allowed to appear on the landing page: the community
|
||||
// strip, the live tester's preview, the creator lab's preview, or the QR card.
|
||||
// One is picked at random out of its slot on every page load.
|
||||
export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const;
|
||||
export type PhotoSlot = (typeof PHOTO_SLOTS)[number];
|
||||
export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
|
||||
typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v);
|
||||
|
||||
// The column arrived after the first strips were already on disk, so add it in
|
||||
// place — `CREATE TABLE IF NOT EXISTS` would silently skip an existing table.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'slot')) {
|
||||
db.exec(`ALTER TABLE photos ADD COLUMN slot TEXT NOT NULL DEFAULT 'strip'`);
|
||||
}
|
||||
}
|
||||
|
||||
export type User = { id: number; email: string };
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
@@ -179,20 +196,21 @@ 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 };
|
||||
export type Photo = { id: number; createdAt: string; slot: PhotoSlot };
|
||||
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
|
||||
|
||||
export function listPhotos(): Photo[] {
|
||||
return db
|
||||
.prepare('SELECT id, created_at AS createdAt FROM photos ORDER BY id DESC')
|
||||
.prepare('SELECT id, created_at AS createdAt, slot FROM photos ORDER BY id DESC')
|
||||
.all() as Photo[];
|
||||
}
|
||||
|
||||
export function listPhotosWithOwner(): AdminPhoto[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.user_id AS userId,
|
||||
photos.mime AS mime, photos.bytes AS bytes, users.email AS email
|
||||
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
|
||||
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
|
||||
ORDER BY photos.id DESC`,
|
||||
)
|
||||
@@ -208,7 +226,8 @@ export function createPhoto(userId: number, file: string, mime: string, bytes: n
|
||||
const info = db
|
||||
.prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(userId, file, mime, bytes, ts);
|
||||
return { id: Number(info.lastInsertRowid), createdAt: ts };
|
||||
// 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' };
|
||||
}
|
||||
|
||||
// The stored file name is only ever used through here, and callers must still
|
||||
@@ -226,6 +245,11 @@ export function deletePhoto(id: number): string | undefined {
|
||||
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;
|
||||
}
|
||||
|
||||
export function deleteAllPhotos(): string[] {
|
||||
const files = (db.prepare('SELECT file FROM photos').all() as { file: string }[]).map((r) => r.file);
|
||||
db.prepare('DELETE FROM photos').run();
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
deleteRecipe,
|
||||
deleteSession,
|
||||
findUserByEmail,
|
||||
isPhotoSlot,
|
||||
listPhotos,
|
||||
listPhotosWithOwner,
|
||||
listRecipes,
|
||||
photoFile,
|
||||
photoPath,
|
||||
sessionUser,
|
||||
setPhotoSlot,
|
||||
updateRecipe,
|
||||
verifyPassword,
|
||||
type Recipe,
|
||||
@@ -51,6 +53,11 @@ const ADMIN_EMAILS = new Set(
|
||||
);
|
||||
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
|
||||
|
||||
// The public shape of an account. `admin` is the allowlist's answer, so the
|
||||
// client can decide whether to offer /admin without a second round trip — and
|
||||
// the server still enforces it on every admin route below.
|
||||
const publicUser = (user: User) => ({ id: user.id, email: user.email, admin: isAdmin(user) });
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: 1024 * 1024,
|
||||
@@ -199,7 +206,7 @@ app.post('/api/auth/signup', async (req, reply) => {
|
||||
const user = createUser(creds.email, creds.password);
|
||||
if (!user) return reply.status(409).send({ error: 'email already registered' });
|
||||
setSession(req, reply, createSession(user.id));
|
||||
return reply.status(201).send({ user });
|
||||
return reply.status(201).send({ user: publicUser(user) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
@@ -212,7 +219,7 @@ app.post('/api/auth/login', async (req, reply) => {
|
||||
const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH);
|
||||
if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' });
|
||||
setSession(req, reply, createSession(row.id));
|
||||
return reply.status(200).send({ user: { id: row.id, email: row.email } });
|
||||
return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email }) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
@@ -227,7 +234,7 @@ app.get('/api/auth/me', async (req, reply) => {
|
||||
// nobody. A 401 here would put a console error on every anonymous visit to
|
||||
// the landing page, which asks the same question to decide what to offer.
|
||||
const user = auth(req);
|
||||
return reply.status(200).send({ user: user ?? null });
|
||||
return reply.status(200).send({ user: user ? publicUser(user) : null });
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (req, reply) => {
|
||||
@@ -289,7 +296,8 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply
|
||||
const mime = sniffImage(body);
|
||||
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
|
||||
|
||||
if (countPhotos(user.id) >= MAX_PHOTOS_PER_USER)
|
||||
// The quota is a fair-use cap on members, not on the curator.
|
||||
if (!isAdmin(user) && countPhotos(user.id) >= MAX_PHOTOS_PER_USER)
|
||||
return reply.status(429).send({ error: 'photo quota reached' });
|
||||
|
||||
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
|
||||
@@ -366,6 +374,20 @@ app.delete('/api/admin/photos', async (req, reply) => {
|
||||
return reply.status(200).send({ removed: files.length });
|
||||
});
|
||||
|
||||
// Curating: which slot on the landing page this photo is allowed to appear in.
|
||||
// The landing page picks one at random per slot, so several photos in one slot
|
||||
// rotate between visits.
|
||||
app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => {
|
||||
const user = admin(req);
|
||||
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
const b = bodyOf(req);
|
||||
if (!b || !isPhotoSlot(b.slot)) return reply.status(400).send({ error: 'invalid slot' });
|
||||
if (!setPhotoSlot(id, b.slot)) return reply.status(404).send({ error: 'photo not found' });
|
||||
return reply.status(200).send({ id, slot: b.slot });
|
||||
});
|
||||
|
||||
app
|
||||
.listen({ port: PORT, host: HOST })
|
||||
.catch((err) => {
|
||||
|
||||
@@ -224,8 +224,29 @@ try {
|
||||
const rows = adminList.body?.photos ?? [];
|
||||
check('an admin lists contributions', adminList.status === 200 && rows.length > 0);
|
||||
check('the admin listing carries the owner', rows.some((r) => /@test\.local$/.test(r.email ?? '')));
|
||||
check('a fresh upload lands in the strip slot', rows.find((r) => r.id === id)?.slot === 'strip');
|
||||
check('a plain member cannot delete', (await user.req(`/admin/photos/${id}`, { method: 'DELETE' })).status === 403);
|
||||
|
||||
// ---- placement ----------------------------------------------------------
|
||||
const patch = (path, body) =>
|
||||
admin.req(path, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
check(
|
||||
'a plain member cannot place a photo',
|
||||
(await user.req(`/admin/photos/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slot: 'qr' }),
|
||||
})).status === 403,
|
||||
);
|
||||
const placed = await patch(`/admin/photos/${id}`, { slot: 'qr' });
|
||||
check('an admin moves a photo to a live slot', placed.status === 200 && placed.body?.slot === 'qr', JSON.stringify(placed.body));
|
||||
check(
|
||||
'the slot is public, the owner is not',
|
||||
((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'qr',
|
||||
);
|
||||
check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slot: 'nope' })).status === 400);
|
||||
check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slot: 'qr' })).status === 404);
|
||||
|
||||
const deleted = await admin.req(`/admin/photos/${id}`, { method: 'DELETE' });
|
||||
check('an admin deletes a contribution', deleted.status === 204, `got ${deleted.status}`);
|
||||
check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${id}/file`)).status === 404);
|
||||
@@ -238,6 +259,12 @@ try {
|
||||
for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status;
|
||||
check('uploads are capped per account', last === 429, `13th upload: ${last}`);
|
||||
|
||||
// The cap is a member fair-use rule; the curator stocks the landing page from
|
||||
// one account, so it must not apply to the allowlist.
|
||||
let adminLast = 0;
|
||||
for (let i = 0; i < 13; i++) adminLast = (await admin.upload(PNG, 'image/png')).status;
|
||||
check('the admin is exempt from the member quota', adminLast === 201, `13th admin upload: ${adminLast}`);
|
||||
|
||||
const cleared = await admin.req('/admin/photos', { method: 'DELETE' });
|
||||
check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body));
|
||||
check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0);
|
||||
|
||||
Reference in New Issue
Block a user