6bbf77860b
- an account can carry a picture: POST /api/auth/avatar (raw bytes, sniffed, replaces and unlinks the old file) and the public GET /api/users/:id/avatar. It rides wherever the account is named — the landing chip, the studio TopBar, the profile form. - new /profile page for members, sharing one Profile form (picture, email, password) with the admin drawer. - /admin is now one bordered frame whose left column is Profile / User account / Pictures / Close. Pictures lists every photo in the system with the slot that shows it; User account lists each account's name, email, picture and contribution count. - account control opens a menu: Admin page + Log out for an admin, Profile + Log out for a member.
508 lines
22 KiB
TypeScript
508 lines
22 KiB
TypeScript
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { basename } from 'node:path';
|
|
import {
|
|
MAX_PHOTO_BYTES,
|
|
MAX_PHOTOS_PER_USER,
|
|
MAX_RECIPE_BYTES,
|
|
SESSION_COOKIE,
|
|
SESSION_MAX_AGE_S,
|
|
DUMMY_HASH,
|
|
countPhotos,
|
|
createPhoto,
|
|
createRecipe,
|
|
createSession,
|
|
createUser,
|
|
deleteAllPhotos,
|
|
deletePhoto,
|
|
deleteRecipe,
|
|
deleteSession,
|
|
findUserByEmail,
|
|
isPhotoSlot,
|
|
listPhotos,
|
|
listPhotosWithOwner,
|
|
listRecipes,
|
|
listUsersWithCounts,
|
|
avatarPath,
|
|
photoFile,
|
|
photoPath,
|
|
sessionUser,
|
|
setPhotoSlot,
|
|
setUserAvatar,
|
|
setUserPassword,
|
|
updateRecipe,
|
|
updateUserEmail,
|
|
userAvatar,
|
|
verifyPassword,
|
|
type Recipe,
|
|
type User,
|
|
} from './db';
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const HOST = '0.0.0.0';
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const MAX_EMAIL = 254;
|
|
const MIN_PASSWORD = 8;
|
|
const MAX_PASSWORD = 200;
|
|
const MAX_NAME = 120;
|
|
|
|
// Comma-separated allowlist from the environment. An allowlist over a role
|
|
// column keeps the privilege out of the database entirely: no migration, and
|
|
// no endpoint that could ever elevate someone.
|
|
const ADMIN_EMAILS = new Set(
|
|
(process.env.ADMIN_EMAILS ?? '')
|
|
.split(',')
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
);
|
|
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.
|
|
// `avatar` is a URL the client can drop straight into an <img>, or null when
|
|
// the account never picked a picture. The `v` is the stored file's own name, so
|
|
// the URL changes with the picture and can be cached hard.
|
|
const publicUser = (user: User) => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
admin: isAdmin(user),
|
|
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
|
|
});
|
|
|
|
const app = Fastify({
|
|
logger: true,
|
|
bodyLimit: 1024 * 1024,
|
|
// The API is only reachable through nginx, so the forwarded headers are the
|
|
// only source of truth for the original scheme (see the Secure cookie flag).
|
|
trustProxy: true,
|
|
});
|
|
|
|
// Bodyless DELETE/logout requests still often carry Content-Type: application/json.
|
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
|
const raw = (body as string).trim();
|
|
if (raw === '') return done(null, undefined);
|
|
try {
|
|
done(null, JSON.parse(raw));
|
|
} catch {
|
|
done(Object.assign(new Error('invalid JSON body'), { statusCode: 400 }));
|
|
}
|
|
});
|
|
|
|
// Photo uploads are the raw image bytes, not multipart: one file per request
|
|
// needs no boundary parsing, so no dependency and no parser attack surface.
|
|
app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => {
|
|
done(null, body);
|
|
});
|
|
|
|
// ---- rate limiting --------------------------------------------------------
|
|
// Fixed window keyed on what the caller is trying to abuse — an email, or a
|
|
// user id — rather than an address: the API sits behind two proxies, so a
|
|
// request's source address is not something it can honestly trust, but the
|
|
// account being attacked cannot be rotated by the attacker.
|
|
// ponytail: in-memory, one container. Swap for @fastify/rate-limit + Redis if
|
|
// the API is ever scaled beyond that.
|
|
function limiter(max: number, windowMs: number) {
|
|
const hits = new Map<string, { n: number; until: number }>();
|
|
return (key: string): boolean => {
|
|
const t = Date.now();
|
|
if (hits.size > 5000) for (const [k, v] of hits) if (v.until <= t) hits.delete(k);
|
|
const row = hits.get(key);
|
|
if (!row || row.until <= t) {
|
|
hits.set(key, { n: 1, until: t + windowMs });
|
|
return true;
|
|
}
|
|
row.n += 1;
|
|
return row.n <= max;
|
|
};
|
|
}
|
|
const allowLogin = limiter(20, 15 * 60_000);
|
|
const allowSignup = limiter(5, 60 * 60_000);
|
|
const allowUpload = limiter(60, 60 * 60_000);
|
|
const tooMany = (reply: FastifyReply) =>
|
|
reply.header('retry-after', '900').status(429).send({ error: 'too_many_requests' });
|
|
|
|
// ---- image sniffing -------------------------------------------------------
|
|
// The declared Content-Type is a claim; the first bytes are evidence. Both must
|
|
// agree, and only these three formats are accepted — SVG in particular is never
|
|
// accepted, because it is a script container that would run on our origin.
|
|
type ImageMime = 'image/jpeg' | 'image/png' | 'image/webp';
|
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
function sniffImage(buf: Buffer): ImageMime | null {
|
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
|
|
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return 'image/png';
|
|
if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP')
|
|
return 'image/webp';
|
|
return null;
|
|
}
|
|
const EXT: Record<ImageMime, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
|
|
const AVATAR_MIME: Record<string, ImageMime> = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
|
|
|
|
// Single error shape for the whole API: { error: "..." }
|
|
app.setErrorHandler((err, req, reply) => {
|
|
const e = err as { statusCode?: number; message?: string };
|
|
const status = e.statusCode && e.statusCode >= 400 ? e.statusCode : 500;
|
|
if (status >= 500) req.log.error(err);
|
|
reply.status(status).send({ error: status >= 500 ? 'internal_error' : (e.message ?? 'error') });
|
|
});
|
|
app.setNotFoundHandler((_req, reply) => reply.status(404).send({ error: 'not_found' }));
|
|
|
|
// ---- cookie helpers (hand-rolled: only one cookie, no plugin needed) ----
|
|
function cookieOf(req: FastifyRequest, name: string): string | undefined {
|
|
const raw = req.headers.cookie;
|
|
if (!raw) return undefined;
|
|
for (const part of raw.split(';')) {
|
|
const eq = part.indexOf('=');
|
|
if (eq === -1) continue;
|
|
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function setSession(req: FastifyRequest, reply: FastifyReply, token: string): void {
|
|
// Secure only where the visitor actually arrived over TLS: nginx forwards the
|
|
// original scheme, so the cookie is hardened in production without breaking
|
|
// local http access to the same build.
|
|
const secure = req.protocol === 'https' ? '; Secure' : '';
|
|
reply.header(
|
|
'set-cookie',
|
|
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`,
|
|
);
|
|
}
|
|
|
|
function clearSession(reply: FastifyReply): void {
|
|
reply.header('set-cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
|
}
|
|
|
|
// ---- validation at the trust boundary ----
|
|
type Json = Record<string, unknown>;
|
|
|
|
function bodyOf(req: FastifyRequest): Json | undefined {
|
|
const b = req.body as unknown;
|
|
return b !== null && typeof b === 'object' && !Array.isArray(b) ? (b as Json) : undefined;
|
|
}
|
|
|
|
function credentials(b: Json): { email: string; password: string } | string {
|
|
const email = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
|
|
const password = typeof b.password === 'string' ? b.password : '';
|
|
if (!email || email.length > MAX_EMAIL || !EMAIL_RE.test(email)) return 'invalid email';
|
|
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD)
|
|
return `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters`;
|
|
return { email, password };
|
|
}
|
|
|
|
function recipePayload(b: Json): { name: string; recipe: Json } | string {
|
|
const name = typeof b.name === 'string' ? b.name.trim() : '';
|
|
const recipe = b.recipe;
|
|
if (!name || name.length > MAX_NAME) return `name must be 1-${MAX_NAME} characters`;
|
|
if (recipe === null || typeof recipe !== 'object' || Array.isArray(recipe)) return 'recipe must be an object';
|
|
if (Buffer.byteLength(JSON.stringify(recipe)) > MAX_RECIPE_BYTES) return 'recipe too large';
|
|
return { name, recipe: recipe as Json };
|
|
}
|
|
|
|
function auth(req: FastifyRequest): User | undefined {
|
|
const token = cookieOf(req, SESSION_COOKIE);
|
|
return token ? sessionUser(token) : undefined;
|
|
}
|
|
|
|
// ---- routes ----
|
|
app.get('/api/health', async () => ({ ok: true }));
|
|
|
|
app.post('/api/auth/signup', async (req, reply) => {
|
|
const b = bodyOf(req);
|
|
if (!b) return reply.status(400).send({ error: 'invalid body' });
|
|
const creds = credentials(b);
|
|
if (typeof creds === 'string') return reply.status(400).send({ error: creds });
|
|
if (!allowSignup(creds.email)) return tooMany(reply);
|
|
if (findUserByEmail(creds.email)) return reply.status(409).send({ error: 'email already registered' });
|
|
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: publicUser(user) });
|
|
});
|
|
|
|
app.post('/api/auth/login', async (req, reply) => {
|
|
const b = bodyOf(req);
|
|
if (!b || typeof b.email !== 'string' || typeof b.password !== 'string')
|
|
return reply.status(400).send({ error: 'invalid body' });
|
|
const email = b.email.trim().toLowerCase();
|
|
if (!allowLogin(email)) return tooMany(reply);
|
|
const row = findUserByEmail(email);
|
|
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: publicUser({ id: row.id, email: row.email, avatar: row.avatar }) });
|
|
});
|
|
|
|
app.post('/api/auth/logout', async (req, reply) => {
|
|
const token = cookieOf(req, SESSION_COOKIE);
|
|
if (token) deleteSession(token);
|
|
clearSession(reply);
|
|
return reply.status(204).send();
|
|
});
|
|
|
|
app.get('/api/auth/me', async (req, reply) => {
|
|
// Signed out is an answer, not an error: "who am I?" with no session is
|
|
// 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 ? publicUser(user) : null });
|
|
});
|
|
|
|
// Profile: the signed-in account edits its own email or password. The current
|
|
// password is required either way, so a stolen cookie alone cannot lock the
|
|
// owner out — and the login limiter caps guesses at it.
|
|
app.patch('/api/auth/me', async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
if (!allowLogin(user.email)) return tooMany(reply);
|
|
const b = bodyOf(req);
|
|
if (!b) return reply.status(400).send({ error: 'invalid body' });
|
|
const row = findUserByEmail(user.email);
|
|
const current = typeof b.currentPassword === 'string' ? b.currentPassword : '';
|
|
if (!row || !verifyPassword(current, row.password_hash))
|
|
return reply.status(403).send({ error: 'invalid password' });
|
|
|
|
let email = user.email;
|
|
if (b.email !== undefined) {
|
|
const next = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
|
|
if (!next || next.length > MAX_EMAIL || !EMAIL_RE.test(next)) return reply.status(400).send({ error: 'invalid email' });
|
|
if (next !== user.email && !updateUserEmail(user.id, next))
|
|
return reply.status(409).send({ error: 'email already registered' });
|
|
email = next;
|
|
}
|
|
if (b.password !== undefined) {
|
|
const password = typeof b.password === 'string' ? b.password : '';
|
|
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD)
|
|
return reply.status(400).send({ error: `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters` });
|
|
setUserPassword(user.id, password);
|
|
}
|
|
return reply.status(200).send({ user: publicUser({ id: user.id, email, avatar: user.avatar }) });
|
|
});
|
|
|
|
app.get('/api/recipes', async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
return reply.status(200).send({ recipes: listRecipes(user.id) });
|
|
});
|
|
|
|
app.post('/api/recipes', async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
const b = bodyOf(req);
|
|
const payload = b && recipePayload(b);
|
|
if (typeof payload === 'string' || !payload)
|
|
return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' });
|
|
const recipe: Recipe = createRecipe(user.id, payload.name, payload.recipe);
|
|
return reply.status(201).send({ recipe });
|
|
});
|
|
|
|
app.put<{ Params: { id: string } }>('/api/recipes/: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: 'recipe not found' });
|
|
const b = bodyOf(req);
|
|
const payload = b && recipePayload(b);
|
|
if (typeof payload === 'string' || !payload)
|
|
return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' });
|
|
const recipe = updateRecipe(user.id, id, payload.name, payload.recipe);
|
|
if (!recipe) return reply.status(404).send({ error: 'recipe not found' });
|
|
return reply.status(200).send({ recipe });
|
|
});
|
|
|
|
app.delete<{ Params: { id: string } }>('/api/recipes/: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: 'recipe not found' });
|
|
if (!deleteRecipe(user.id, id)) return reply.status(404).send({ error: 'recipe not found' });
|
|
return reply.status(204).send();
|
|
});
|
|
|
|
// ---- contributed strip photos -------------------------------------------
|
|
// 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() }));
|
|
|
|
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' });
|
|
if (!allowUpload(String(user.id))) return tooMany(reply);
|
|
|
|
const body = req.body;
|
|
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' });
|
|
if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' });
|
|
|
|
const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
|
const mime = sniffImage(body);
|
|
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
|
|
|
|
// 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]}`;
|
|
writeFileSync(photoPath(file), body, { flag: 'wx' });
|
|
const photo = createPhoto(user.id, file, mime, body.length);
|
|
return reply.status(201).send({ photo });
|
|
});
|
|
|
|
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
|
|
// under a server-generated name. The picture it replaces goes with it.
|
|
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
|
|
const user = auth(req);
|
|
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
|
if (!allowUpload(String(user.id))) return tooMany(reply);
|
|
|
|
const body = req.body;
|
|
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' });
|
|
if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' });
|
|
|
|
const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
|
const mime = sniffImage(body);
|
|
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
|
|
|
|
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
|
|
writeFileSync(avatarPath(file), body, { flag: 'wx' });
|
|
const previous = setUserAvatar(user.id, file);
|
|
if (previous) unlinkAvatar(previous);
|
|
return reply.status(200).send({ user: publicUser({ ...user, avatar: file }) });
|
|
});
|
|
|
|
// Public on purpose: an avatar sits next to a name on the landing page, so
|
|
// there is nothing here a session would protect.
|
|
app.get<{ Params: { id: string } }>('/api/users/:id/avatar', async (req, reply) => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
|
|
const file = userAvatar(id);
|
|
const type = file ? AVATAR_MIME[file.split('.').pop() ?? ''] : undefined;
|
|
if (!file || !type) return reply.status(404).send({ error: 'not_found' });
|
|
let data: Buffer;
|
|
try {
|
|
data = readFileSync(avatarPath(file));
|
|
} catch {
|
|
return reply.status(404).send({ error: 'not_found' });
|
|
}
|
|
// The URL carries the file's own name as a version, so it can never go stale.
|
|
return reply
|
|
.header('content-type', type)
|
|
.header('cache-control', 'public, max-age=31536000, immutable')
|
|
.send(data);
|
|
});
|
|
|
|
app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
|
|
const row = photoFile(id);
|
|
// The column is server-generated, but re-check it on the way out: a single
|
|
// path segment is the only thing that can ever be opened.
|
|
if (!row || basename(row.file) !== row.file) return reply.status(404).send({ error: 'not_found' });
|
|
let data: Buffer;
|
|
try {
|
|
data = readFileSync(photoPath(row.file));
|
|
} catch {
|
|
return reply.status(404).send({ error: 'not_found' });
|
|
}
|
|
return reply
|
|
.header('content-type', row.mime)
|
|
.header('x-content-type-options', 'nosniff')
|
|
// Belt and braces on top of the mime allowlist: even a hostile still cannot
|
|
// act as a document on this origin.
|
|
.header('content-security-policy', "default-src 'none'; sandbox")
|
|
// Short, not immutable: an admin deleting a contribution has to be able to
|
|
// take it off the web, and a cached copy would outlive the removal.
|
|
.header('cache-control', 'public, max-age=60')
|
|
.send(data);
|
|
});
|
|
|
|
// ---- admin ---------------------------------------------------------------
|
|
// Moderation only: the allowlist can list everything and clean up. There is
|
|
// deliberately no endpoint here that grants the privilege itself.
|
|
function admin(req: FastifyRequest): User | { status: number } {
|
|
const user = auth(req);
|
|
if (!user) return { status: 401 };
|
|
if (!isAdmin(user)) return { status: 403 };
|
|
return user;
|
|
}
|
|
|
|
function unlink(file: string): void {
|
|
try {
|
|
unlinkSync(photoPath(file));
|
|
} catch {
|
|
// Already gone; the row is what matters.
|
|
}
|
|
}
|
|
|
|
function unlinkAvatar(file: string): void {
|
|
try {
|
|
unlinkSync(avatarPath(file));
|
|
} catch {
|
|
// Already gone; the row is what matters.
|
|
}
|
|
}
|
|
|
|
// Accounts and how much each one has contributed — the "who is this" half of
|
|
// moderation. `admin` is the allowlist's answer, not a stored column.
|
|
app.get('/api/admin/users', async (req, reply) => {
|
|
const user = admin(req);
|
|
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
|
return reply.status(200).send({
|
|
users: listUsersWithCounts().map((u) => ({
|
|
...u,
|
|
avatar: u.avatar ? `/api/users/${u.id}/avatar?v=${u.avatar.split('.')[0]}` : null,
|
|
admin: ADMIN_EMAILS.has(u.email),
|
|
})),
|
|
});
|
|
});
|
|
|
|
app.get('/api/admin/photos', async (req, reply) => {
|
|
const user = admin(req);
|
|
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
|
return reply.status(200).send({ photos: listPhotosWithOwner() });
|
|
});
|
|
|
|
app.delete<{ 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 file = deletePhoto(id);
|
|
if (!file) return reply.status(404).send({ error: 'photo not found' });
|
|
unlink(file);
|
|
return reply.status(204).send();
|
|
});
|
|
|
|
app.delete('/api/admin/photos', async (req, reply) => {
|
|
const user = admin(req);
|
|
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
|
const files = deleteAllPhotos();
|
|
for (const file of files) unlink(file);
|
|
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) => {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
});
|