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