Files
RecipesCam/docker/backend/src/server.ts
T
3dtours 3f5d2cd017 web: give the landing hero a column of the day's best-rated recipes
The hero was a single block of copy with nothing beside it, so a visitor landing
on the page saw no photograph at all until they scrolled. It now splits into
copy + an award card: the highest-rated photos of the current window, one frame
per photo, each labelled for the window it came from.

The frame set is the day's top-rated first, then the week's, both deduped by
photo id, so a photo that is both this day's and this week's best is drawn once
and keeps the day's label — that is the tighter of the two windows and the more
specific claim. Measured on the live data (2026-09-23 UTC, week = ISO Monday
2026-09-21): GET /api/highlights came back with day [] and week a full five
frames (ids 12,13,51,50,3, every one avg 5, n 1). The day window is empty simply
because no rating had landed since 00:00 UTC, and an empty day window must not
empty the column — hence the union rather than a fallback: whatever each window
has, merged, deduped.

Frames change the way the film strip already does, so the card reuses that
machinery rather than inventing a second one: the same .lp-arrow dots and the
same FrameArrows component, which already renders nothing under two frames.
Under two frames the card also keeps still — no arrows and no timer, because a
single frame has nothing to advance to and a timer that swaps a frame for itself
is just a repaint. Five seconds a frame, one second of crossfade: all frames are
stacked in the same box and the active one is the only one at opacity 1, each
transitioning its own opacity over 1s, so the outgoing frame fades out over the
same second the incoming one fades in and the box never flashes empty. Measured
in the built app: mid-step opacities 0.32, 0.68, 0.00, 0.00, 0.00 at the halfway
point of a step, and transitionDuration exactly 1s on every slide. Stepping by
hand restarts that clock instead of letting the old 5s fire on top of the new
frame — an arrow step to 2 then waited 4.2s still sat on that frame, where
without the restart it would have moved on at 5s from the previous frame's
start.

The rating is shown on the frame because it is the whole reason the frame is
there: avg to one decimal, plus the vote count as "1 vote"/"N votes" — one
decent vote and one outstanding vote are not the same window, and the reader
can tell them apart at a glance. Score is mono, bottom-left, over a text
shadow.

Backend side this is one query and one route. topRatedPhotos(since, limit)
joins ratings to photos on CAST(substr(ratings.key, 7) AS INTEGER), since
ratings keys are the strings "photo:<id>"; it filters ratings.key LIKE 'photo:%'
so a look: vote can never award a frame — there is no look to show — and
photos.consent = 1, so a photo pulled from public display is pulled from the
awards with it. Ordering is avg DESC, n DESC, at DESC, id DESC: best average,
then the better-supported average when averages tie, then the freshest, then id
only to make the order total and the frame set stable between requests. avg
comes back rounded to 2dp. GET /api/highlights computes the two windows in UTC
— midnight, and ISO Monday midnight via midnight - ((getUTCDay()+6)%7)*86400000
— and returns { highlights: { day, week } }. HIGHLIGHT_LIMIT is 5.

The column is 340px on the right of the copy, stacking under it below 980px.
Measured on the built app at 1280px: copy ends at 902, card starts at 948, same
hero row, card exactly 340px wide, five slides for five frames, label "Recipe
of the week", score "5.0★1 vote", the two arrows the only .lp-arrow inside
.lp-award, meta #CLASSIC_VIVIDIPES. At 900px the card sits under the copy. With
the window forced to one frame the card draws 0 arrows and 1 slide; with both
windows empty there is no .lp-award and the hero is not split at all, so an
unrated install looks exactly as it did before.

Verified:
  award-column-probe.cjs (new, scratchpad) — geometry, arrows, crossfade
    opacities, the 5s auto step, the manual step's clock restart, the one-frame
    and no-frame windows. 18/18 on http://localhost:8090.
  backend npm test — 166 passed, 0 failed, with four new checks in the ratings
    section: a vote lands in today's and this week's window, a look: subject is
    never an award, and a photo drops off the awards once deleted. The ratings
    and photos suites cover the joins the new query leans on.
  landing-test.cjs, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21,
    landing-photo-guard-test.cjs 21/21 — 0 fail against the built app.
  web tsc --noEmit clean. Dark and light themes both eyeballed on the built
    app (award-hero-dark.png, award-hero-light.png).

ponytail: the card re-fetches on the page's own reload() rather than polling, so
a rating cast while the tab sits open will not surface until the next reload;
the awards are a landing-page flourish, not a live feed — when they need to be
live, poll the same route on the timer the frames already run. The card's
box-shadow is the dark card's, one rgba(0,0,0,0.35), and reads heavy on the
light theme next to .lp-recipe-card's light-specific shadow; left alone rather
than adding a token for one property.
2026-09-23 18:17:50 +07:00

1070 lines
47 KiB
TypeScript

import { recipeFile } from './recipeFile';
import { sendVerificationMail } from './mailer';
import { placeName } from './place';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { createHash, 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,
createEmailVerification,
createEvent,
createPhoto,
createRecipe,
createSession,
createUser,
deleteAllPhotos,
deletePhoto,
deletePhotoOf,
deleteRecipe,
deleteSession,
deleteUser,
eventStats,
findUserByEmail,
findUserById,
isPhotoSlot,
listPhotos,
listPhotosByUser,
listPhotosWithOwner,
listRecipes,
listUsersWithCounts,
replacePhoto,
avatarPath,
photoFile,
photoFileOwned,
photoPath,
photoPreset,
rateLook,
ratingsFor,
sessionUser,
setPhotoSlots,
setPhotoConsent,
setUserAvatar,
setUserBlocked,
setUserPassword,
setUserRemoved,
topRatedPhotos,
updateRecipe,
updateUserEmail,
userAvatar,
verifyEmailToken,
verifyPassword,
type PhotoMeta,
type Recipe,
type User,
type EventKind,
type PhotoSlot,
} 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());
// What an account is worth. A signup proves nothing until the address it gave
// is confirmed, so an unverified account is a guest with a name: the PRO tier,
// its own listings and every write stay shut. Admins come from the
// deployment's own allowlist — trusted by construction, so no letter is needed
// and a broken relay cannot lock the operator out of their own site.
const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user);
// 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.
// `verified` is the studio's PRO gate: true only for a proven address.
// `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),
verified: isVerified(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;
}
// The gate every personal route takes instead of `auth`. Two different
// refusals, because the studio acts on them differently: 401 sends a guest to
// the sign-in dialog, 403 asks a signed-in account to open its mail.
function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
if (!isVerified(user)) {
void reply.status(403).send({ error: 'email not verified' });
return undefined;
}
return user;
}
// The verification link has to work from wherever the visitor actually
// arrived — the deployment's domain, an IP:port, localhost in development.
// nginx forwards the original Host and scheme, so the request already knows
// both; the header is a chain, and the first hop is the one the browser used.
function originOf(req: FastifyRequest): string {
const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v)?.split(',')[0].trim();
const host = first(req.headers['x-forwarded-host']) || req.headers.host || '';
const proto = first(req.headers['x-forwarded-proto']) || req.protocol || 'http';
return host ? `${proto}://${host}` : '';
}
// Mints the single live token and hands the link to the mailer. The URL is the
// API's own route, so a click needs no page of its own (see the redirect
// there). A relay that cannot send is not an error here: the link is in the
// log, and the account can ask again.
function sendVerification(req: FastifyRequest, user: User): void {
const token = createEmailVerification(user.id);
sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, (msg) => req.log.info(msg));
}
// ---- analytics ------------------------------------------------------------
// The page counter. It stores nothing that identifies a visitor: the address
// becomes a salted hash (enough to count uniques) and a coarse place, then it
// is dropped. A full UA/GeoIP database would be a dependency for numbers nobody
// acts on at this size, so both readings stay deliberately plain.
const MAX_PATH = 120;
const MAX_TARGET = 60;
const isEventKind = (v: unknown): v is EventKind => v === 'view' || v === 'click';
// The address the visitor actually used. nginx (and the proxy in front of it)
// append to X-Forwarded-For, so the left-most entry is the client; `req.ip` is
// the fallback for a direct call.
function clientIp(req: FastifyRequest): string {
const chain = req.headers['x-forwarded-for'];
const raw = Array.isArray(chain) ? chain[0] : chain;
return raw?.split(',')[0]?.trim() || req.ip || '';
}
const STATS_SALT = process.env.STATS_SALT ?? 'recipescam-analytics';
const visitorOf = (ip: string) => createHash('sha256').update(`${STATS_SALT}:${ip}`).digest('hex').slice(0, 16);
// Family + major version, the OS, and the form factor. Order matters: Edge and
// Opera also claim Chrome, and Safari claims nothing else.
const BROWSERS: [string, RegExp][] = [
['Edge', /Edg\/(\d+)/],
['Opera', /OPR\/(\d+)/],
['Samsung Internet', /SamsungBrowser\/(\d+)/],
['Firefox', /Firefox\/(\d+)/],
['Chrome', /Chrome\/(\d+)/],
// Safari's own token comes last and may sit past "Mobile/…", which is why the
// gap between the version and the name is not just digits.
['Safari', /Version\/(\d+)[^)]* Safari/],
];
const SYSTEMS: [string, RegExp][] = [
['Windows', /Windows NT/],
['Android', /Android/],
['iOS', /iPhone|iPad|iPod/],
['macOS', /Mac OS X/],
['Linux', /Linux|X11/],
];
function parseUa(ua: string): { browser: string | null; os: string | null; device: string } {
const u = ua.toLowerCase();
// Crawlers and headless browsers never count as traffic: a bot that runs the
// beacon would otherwise land in the stats page as a visitor — see the guard
// in /api/events and the `device <> 'bot'` filter on every stats query.
// No UA at all is a script, not a browser: every real one sends a string.
const device = !u.trim() || /bot|crawler|spider|crawl|slurp|headless|puppeteer|playwright|phantomjs|lighthouse|httpclient|curl|wget|python-requests/.test(u)
? 'bot'
: /ipad|tablet|android(?!.*mobile)/.test(u)
? 'tablet'
: /mobi|iphone|ipod|android/.test(u)
? 'mobile'
: 'desktop';
const match = (table: [string, RegExp][]) => {
for (const [name, re] of table) {
const m = re.exec(ua);
// Some patterns carry a version group, some only name the family.
if (m) return m[1] ? `${name} ${m[1]}` : name;
}
return null;
};
return { browser: match(BROWSERS), os: match(SYSTEMS), device };
}
interface GeoPlace {
country: string;
region: string;
city: string;
}
// Resolved at insert, cached per address. ip-api's free endpoint needs no key;
// when it is unreachable or rate-limited the row keeps a null place and the
// stats page shows "unknown" rather than failing the request.
const geoCache = new Map<string, GeoPlace | null>();
const PRIVATE_IP = /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|f[cd]|localhost)/i;
async function lookupGeo(ip: string): Promise<GeoPlace | null> {
if (!ip || PRIVATE_IP.test(ip)) return null;
const hit = geoCache.get(ip);
if (hit !== undefined) return hit;
if (geoCache.size > 2000) geoCache.clear();
let place: GeoPlace | null = null;
try {
const res = await fetch(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city`,
{ signal: AbortSignal.timeout(1500) },
);
const b = (await res.json()) as { status?: string; country?: string; regionName?: string; city?: string };
if (b.status === 'success') place = { country: b.country ?? '', region: b.regionName ?? '', city: b.city ?? '' };
} catch {
// Offline, blocked or rate-limited: a missing place, not a failed visit.
}
geoCache.set(ip, place);
return place;
}
// ---- routes ----
app.get('/api/health', async () => ({ ok: true }));
// The counter's one write. Public and unauthenticated by design: it is fired by
// a beacon from every page, so it must never be a way to probe who is signed in
// — it answers 204 whatever happens and echoes nothing back.
const allowTrack = limiter(300, 60_000);
app.post('/api/events', async (req, reply) => {
const b = bodyOf(req);
const ip = clientIp(req);
if (!b || !isEventKind(b.kind) || !allowTrack(ip || 'unknown')) return reply.status(204).send();
const text = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
const ua = parseUa(typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '');
// A bot's visit is not traffic: it is answered, never stored, and never
// looked up against the geo service either. The stats queries filter `bot`
// out as well, so rows written before this guard stay out of the numbers.
if (ua.device === 'bot') return reply.status(204).send();
const geo = await lookupGeo(ip);
createEvent({
kind: b.kind,
path: text(b.path, MAX_PATH) || '/',
target: text(b.target, MAX_TARGET) || null,
visitor: visitorOf(ip || 'unknown'),
userId: auth(req)?.id ?? null,
country: geo?.country || null,
region: geo?.region || null,
city: geo?.city || null,
browser: ua.browser,
os: ua.os,
device: ua.device,
});
return reply.status(204).send();
});
// The film strip's ratings. Public and unauthenticated for the same reason the
// counter is: any visitor may score a frame once, and the vote is held against
// the salted-address hash rather than an account. The subject is a photo id or
// a built-in look's tag, so both kinds of frame are rated through one route.
const allowRate = limiter(120, 60_000);
const RATING_KEY = /^[A-Za-z0-9:_-]{1,64}$/;
app.get('/api/ratings', async (req) => ({ ratings: ratingsFor(visitorOf(clientIp(req) || 'unknown')) }));
app.post('/api/ratings', async (req, reply) => {
const b = bodyOf(req);
const ip = clientIp(req);
const key = typeof b?.key === 'string' ? b.key.trim() : '';
const stars = Math.round(Number(b?.stars));
if (!RATING_KEY.test(key) || !Number.isFinite(stars) || stars < 1 || stars > 5)
return reply.status(400).send({ error: 'invalid rating' });
if (!allowRate(ip || 'unknown')) return tooMany(reply);
const visitor = visitorOf(ip || 'unknown');
rateLook(key, visitor, stars);
return reply.status(200).send({ key, rating: ratingsFor(visitor)[key] });
});
// The landing hero's award column: the day's best-rated contribution and the
// week's, read off the same votes the strip casts — a window instead of a
// lifetime. The windows are the server's clock in UTC, so every visitor and
// every cache agrees on which frame is today's; the week starts on Monday, the
// ISO week the page's own copy implies. Public like the tally it is drawn from.
const HIGHLIGHT_LIMIT = 5;
app.get('/api/highlights', async () => {
const now = new Date();
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const monday = midnight - ((new Date(midnight).getUTCDay() + 6) % 7) * 86_400_000;
return {
highlights: {
day: topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT),
week: topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT),
},
};
});
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' });
// The session is granted anyway. An unverified account is served at the guest
// tier, but it is a guest that can see the banner saying so and ask for its
// link again — which needs to be somebody.
sendVerification(req, user);
setSession(req, reply, createSession(user.id));
return reply.status(201).send({ user: publicUser(user) });
});
// Where the mail link lands. A plain GET, no session required: the visitor may
// well open it in another browser, or on the phone that owns the address. It
// answers with a redirect rather than JSON for the same reason — the landing
// page is what a browser should show. A bad or expired token is not an error
// page, it is the same page saying the link did not work.
app.get('/api/auth/verify', async (req, reply) => {
const raw = (req.query as { token?: unknown } | undefined)?.token;
const userId = typeof raw === 'string' && raw.length <= 128 ? verifyEmailToken(raw) : null;
return reply.redirect(`${originOf(req)}/?verified=${userId ? 1 : 0}`, 303);
});
// The banner's own button. Capped like signup and keyed on the address, so the
// route is not a way to mail a stranger repeatedly.
const allowResend = limiter(3, 60 * 60_000);
app.post('/api/auth/resend-verification', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (isVerified(user)) return reply.status(200).send({ ok: true, verified: true });
if (!allowResend(user.email)) return tooMany(reply);
sendVerification(req, user);
return reply.status(200).send({ ok: true });
});
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' });
// Moderation answers after the password check, so the state of an account is
// not something an attacker can probe without its credentials.
if (row.blocked) return reply.status(403).send({ error: 'account blocked' });
if (row.deletedAt) return reply.status(403).send({ error: 'account removed' });
setSession(req, reply, createSession(row.id));
return reply.status(200).send({ user: publicUser(row) });
});
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) => {
// `auth`, not `requirePro`: editing your own profile is how an unverified
// account fixes a mistyped address, so this route stays open to it.
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;
let emailVerified = user.emailVerified;
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) {
if (!updateUserEmail(user.id, next)) return reply.status(409).send({ error: 'email already registered' });
email = next;
// The tier follows the address that earned it: a new one is unproven
// until its own link is followed, so the flag goes back to 0 (the update
// cleared the row) and a letter goes out.
emailVerified = 0;
sendVerification(req, { ...user, email });
}
}
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({ ...user, email, emailVerified }) });
});
app.get('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
return reply.status(200).send({ recipes: listRecipes(user.id) });
});
app.post('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
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 = requirePro(req, reply);
if (!user) return;
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 = requirePro(req, reply);
if (!user) return;
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 -------------------------------------------
// 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;
// The recipe travels as one URL-encoded query parameter beside the labels; this
// is the ceiling of what the studio can hand back (a real one is well under 1KB).
const RECIPE_PARAM_MAX = 3000;
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),
// The look that made the pixels, so the studio can open the photo again.
// It rides the query string like the labels do (the body is the raw image),
// so it is capped here: a recipe this app writes is well under a kilobyte.
recipe: parseRecipeParam(q.recipe),
// Consent is the uploader's, and only an explicit false opts out.
consent: q.consent !== '0',
};
}
// Anything the studio could not read back as a recipe object is dropped, never
// stored half-parsed: the row must not carry a blob that breaks the folder.
function parseRecipeParam(raw: unknown): unknown {
if (typeof raw !== 'string' || raw.length === 0) return undefined;
if (raw.length > RECIPE_PARAM_MAX) return undefined;
try {
const value: unknown = JSON.parse(raw);
return value && typeof value === 'object' ? value : undefined;
} catch {
return undefined;
}
}
// 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 = requirePro(req, reply);
if (!user) return;
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
// The name of a coordinate, for the stamp. A browser cannot ask the OS the way
// the phone app does, so the lookup happens here — which is also why it is a
// pro route: every miss takes a call out to the public geocoder.
app.get('/api/place', async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
const q = req.query as { lat?: string; lng?: string };
const lat = Number(q.lat);
const lng = Number(q.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180)
return reply.status(400).send({ error: 'invalid coordinates' });
return reply.status(200).send({ place: await placeName(lat, lng) });
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
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, photoMeta(req));
return reply.status(201).send({ photo });
});
// Re-saving one of the caller's own photos: the same bytes-in, meta-on-the-
// query shape as the upload, but it lands on the row they named instead of
// making a new one. The pixels it displaces are gone for good, so the look they
// carried steps into the row's history (see replacePhoto) and the old file is
// unlinked. Owner only — the user_id in the WHERE is the authorisation.
app.put<{ Params: { id: string } }>(
'/api/photos/:id',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
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 previous = photoFile(id);
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
writeFileSync(photoPath(file), body, { flag: 'wx' });
const photo = replacePhoto(user.id, id, file, mime, body.length, photoMeta(req));
if (!photo) {
unlink(file);
return reply.status(404).send({ error: 'photo not found' });
}
if (previous && previous.file !== file) unlink(previous.file);
return reply.status(200).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 = requirePro(req, reply);
if (!user) return;
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);
});
// The editable base behind a saved render: the pixels as they went INTO the
// look, before the frame, the grade and the stamp were applied. `/file` is what
// the folder and the landing strip show — the finished frame — and this is the
// layer underneath it. The studio loads it when a saved photo is opened again,
// so the stored look lands on the original instead of a second time on its own
// output (which doubled the frame and stacked the grade). Owner only: the
// render may be public on the strip, the base never is.
app.put<{ Params: { id: string } }>(
'/api/photos/:id/base',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const row = photoFileOwned(user.id, id);
if (!row) return reply.status(404).send({ error: 'photo not found' });
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' });
// One base per photo, beside the render and overwritten in place by a
// re-save: a replace throws the old file away, and `unlink` takes this
// with it.
writeFileSync(photoPath(`${row.file}.b`), body);
return reply.status(204).send();
},
);
app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
const row = photoFileOwned(user.id, id);
if (!row || basename(row.file) !== row.file) return reply.status(404).send({ error: 'not_found' });
let data: Buffer;
let mime: ReturnType<typeof sniffImage> = null;
try {
data = readFileSync(photoPath(`${row.file}.b`));
mime = sniffImage(data);
} catch {
return reply.status(404).send({ error: 'not_found' });
}
// A row saved before the base existed has no `.b` beside it, and the studio
// falls back to the render (the old, look-already-baked behaviour).
if (!mime) return reply.status(404).send({ error: 'not_found' });
return reply
.header('content-type', mime)
.header('x-content-type-options', 'nosniff')
.header('cache-control', 'private, max-age=60')
.send(data);
});
// The QR card's payload: the `.recipe` file the app reads back on IMPORT, built
// from the look the photo was uploaded with. Public like the strip, but only
// for a row the curator ticked into the `qr` section — that checkbox is the
// whole permission. Everything else is a 404 rather than a 403, so the route
// cannot be used to probe which photos carry a look.
app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', 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 = photoPreset(id);
if (!row || row.recipe === null || !row.slots.includes('qr')) return reply.status(404).send({ error: 'not_found' });
return reply
.header('content-type', 'application/xml; charset=utf-8')
.header('x-content-type-options', 'nosniff')
.header('content-disposition', `attachment; filename="recipescam-${id}.recipe"`)
.header('cache-control', 'public, max-age=60')
.send(recipeFile(row.recipe));
});
// The uploader's own permission switch: may this photo show on the landing
// strip? Only the owner may flip it (an admin curates the slot, not the
// consent), and only their own row is reachable — the user_id in the WHERE is
// the authorisation.
app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const body = (req.body ?? {}) as { consent?: unknown };
if (typeof body.consent !== 'boolean') return reply.status(400).send({ error: 'invalid consent' });
if (!setPhotoConsent(user.id, id, body.consent)) return reply.status(404).send({ error: 'photo not found' });
return reply.status(200).send({ id, consent: body.consent });
});
// 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 = requirePro(req, reply);
if (!user) return;
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.
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 {
// The editable base lives beside the render as `<file>.b`; the row is gone,
// so it goes too.
for (const name of [file, `${file}.b`]) {
try {
unlinkSync(photoPath(name));
} 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),
blocked: !!u.blocked,
removed: !!u.deletedAt,
})),
});
});
// Moderation of an account. `blocked` stops it signing in; `removed` takes it
// (and its photos) off the site while staying restorable. Both are reversible,
// which is why they share one route — the hard delete is the DELETE below.
// An allowlisted account is never a target: the allowlist is the only source of
// admin privilege, so this also makes "delete yourself" impossible.
function moderatable(reply: FastifyReply, id: number): number | null {
if (!Number.isInteger(id) || id <= 0) {
reply.status(404).send({ error: 'user not found' });
return null;
}
const target = findUserById(id);
if (!target) {
reply.status(404).send({ error: 'user not found' });
return null;
}
if (ADMIN_EMAILS.has(target.email.toLowerCase())) {
reply.status(403).send({ error: 'cannot modify an admin account' });
return null;
}
return id;
}
app.patch<{ Params: { id: string } }>('/api/admin/users/: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 = moderatable(reply, Number(req.params.id));
if (id === null) return reply;
const b = bodyOf(req);
if (!b) return reply.status(400).send({ error: 'invalid body' });
if (b.blocked !== undefined) {
if (typeof b.blocked !== 'boolean') return reply.status(400).send({ error: 'invalid blocked' });
setUserBlocked(id, b.blocked);
}
if (b.removed !== undefined) {
if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' });
setUserRemoved(id, b.removed);
}
const row = listUsersWithCounts().find((u) => u.id === id);
return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } });
});
app.delete<{ Params: { id: string } }>('/api/admin/users/: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 = moderatable(reply, Number(req.params.id));
if (id === null) return reply;
const removed = deleteUser(id);
if (!removed) return reply.status(404).send({ error: 'user not found' });
for (const file of removed.photos) unlink(file);
if (removed.avatar) unlinkAvatar(removed.avatar);
return reply.status(204).send();
});
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() });
});
// The traffic screen's one read: page views, feature clicks and how they break
// down by page, feature, place, browser, system and device. `days` is clamped
// rather than rejected — a bad range must not cost the whole page.
app.get('/api/admin/stats', async (req, reply) => {
const user = admin(req);
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
const asked = Number((req.query as { days?: string }).days);
const days = Number.isFinite(asked) ? Math.min(365, Math.max(1, Math.floor(asked))) : 30;
return reply.status(200).send(eventStats(days));
});
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: the landing sections this photo is allowed to appear in. A photo
// may sit in several at once — each section picks one of its own at random per
// visit, so several photos in one section rotate. An empty set takes it off
// the landing without deleting the row.
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);
const slots = b?.slots;
if (!Array.isArray(slots) || !slots.every((s) => isPhotoSlot(s)))
return reply.status(400).send({ error: 'invalid slots' });
const set: PhotoSlot[] = [...new Set(slots as PhotoSlot[])];
if (!setPhotoSlots(id, set)) return reply.status(404).send({ error: 'photo not found' });
return reply.status(200).send({ id, slots: set });
});
app
.listen({ port: PORT, host: HOST })
.catch((err) => {
app.log.error(err);
process.exit(1);
});