291968a342
The landing's award column read two windows of the clock — today's votes and
this ISO week's — and both of them empty meant no column at all: no
[data-key="lp-award"], and the hero back to a single block of copy. That is the
normal state of the install, not an edge case. On the live data the newest vote
is 2026-09-22T09:48 and this week's window opened 2026-09-28T00:00, so
GET /api/highlights answered {"day":[],"week":[]} with HTTP 200 while
/api/photos handed out fifteen frames: the query was right and the clock had
simply moved past every vote.
So /api/highlights grows a third list, `ever`: every vote ever cast, top-rated
first, computed only when both windows come back empty — a day with one vote in
it pays nothing for the extra query. The column draws day, then week, then ever,
deduped by photo id, so a frame keeps the tighter claim it won. The label
follows the frame: `ever` claims no window — "Most-loved recipe" / "Công thức
được yêu nhất" — because dressing a month-old best as today's is a lie the page
does not have to tell. A vote in the window still reads "Recipe of the day" or
"Recipe of the week" exactly as before.
Verified:
backend npm test — 183 passed, 0 failed. Three new checks in the ratings
section: a vote older than both windows leaves them empty, the all-time
bests stand in for them, and with nothing ever rated there is no frame at
all. The aged vote is backdated in the throwaway database, because both
windows are the clock's and no API path can cast a vote in the past.
Live: GET /api/highlights returns `ever` with five frames (ids 12,13,51,50,3,
every one avg 5, n 1) and 8090's landing draws .lp-award 440px wide beside
the copy — 5 slides, 1 standing, 2 arrows, label "Most-loved recipe", score
"5.0★1 vote", meta #LC_LANDSCAPE_VIVID. The column stepped slide-12 →
slide-13 across one 5s clock, and the page logged 0 console errors.
frontend tsc --noEmit clean; the served asset index-DPZxk4Vo.js matches dist/.
ponytail: the fallback is a flat all-time list, not a walk back through the
weeks to the newest one that has votes — the query stays one line and the label
stays honest; when the column should also read "Recipe of last week", walk the
windows back instead.
1220 lines
55 KiB
TypeScript
1220 lines
55 KiB
TypeScript
import { recipeFile } from './recipeFile';
|
|
import { sendVerificationMail } from './mailer';
|
|
import { placeName } from './place';
|
|
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, join } from 'node:path';
|
|
import {
|
|
DATA_DIR,
|
|
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,
|
|
db,
|
|
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,
|
|
verifyEmailCode,
|
|
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);
|
|
});
|
|
|
|
// A restore body is a whole data dir, so it is the one body far past the
|
|
// instance limit. Buffered, not streamed: `tar` needs the archive to be
|
|
// seekable, and the route is admin-gated (see gateAdmin) before a byte is read.
|
|
const RESTORE_LIMIT = 4 * 1024 * 1024 * 1024;
|
|
app.addContentTypeParser('application/gzip', { parseAs: 'buffer', bodyLimit: RESTORE_LIMIT }, (_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 code and hands both 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 code and the link
|
|
// are in the log, and the account can ask again.
|
|
function sendVerification(req: FastifyRequest, user: User): void {
|
|
const { token, code } = createEmailVerification(user.id);
|
|
sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, code, (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.
|
|
// A quiet day — and a quiet Monday, when the week's window is minutes old — is
|
|
// the normal state of an install, and an empty window must not empty the hero,
|
|
// so the all-time bests stand in under a label of their own. Asked for only
|
|
// when both windows are empty: a day with one vote pays nothing for it.
|
|
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;
|
|
const day = topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT);
|
|
const week = topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT);
|
|
const ever = day.length || week.length ? [] : topRatedPhotos('', HIGHLIGHT_LIMIT);
|
|
return { highlights: { day, week, ever } };
|
|
});
|
|
|
|
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 code from the same letter, typed into the page the visitor signed up on.
|
|
// A POST, unlike the link: it is a mutation the visitor submits on purpose, and
|
|
// a code in a GET query string would land in every proxy log on the way.
|
|
//
|
|
// No limiter of its own: the row carries the cap (MAX_CODE_ATTEMPTS) and a
|
|
// fresh code costs one of the three resends an hour, so five guesses per code
|
|
// is the budget an attacker has either way — and the cap lives with the secret.
|
|
app.post('/api/auth/verify-code', 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 });
|
|
const b = bodyOf(req);
|
|
const code = b && typeof b.code === 'string' ? b.code.trim() : '';
|
|
// Six digits or nothing, so the counter only ever counts real guesses.
|
|
if (!/^\d{6}$/.test(code)) return reply.status(400).send({ error: 'invalid code' });
|
|
const result = verifyEmailCode(user.id, code);
|
|
if (result === 'locked') return reply.header('retry-after', '900').status(429).send({ error: 'too_many_attempts' });
|
|
if (result !== 'ok') return reply.status(400).send({ error: 'invalid code' });
|
|
return reply.status(200).send({ ok: true, verified: true });
|
|
});
|
|
|
|
// 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 });
|
|
});
|
|
|
|
// ---- backup / restore -----------------------------------------------------
|
|
// The deployment's whole state is DATA_DIR: one SQLite file plus the two media
|
|
// folders. The archive is a plain tar.gz of exactly those three, which makes
|
|
// one artefact serve both jobs — the operator's backup, and the data package
|
|
// that moves an install onto another box (the restore route below takes the
|
|
// very same file).
|
|
const BACKUP_PATHS = ['recipescam.db', 'uploads', 'avatars'];
|
|
|
|
// Sortable and filename-safe, so two backups in one day never collide.
|
|
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
|
|
// Authenticate before the body is read. Every other route can afford to parse
|
|
// first and refuse after; the restore route cannot, because a rejected caller
|
|
// would already have cost the process gigabytes of memory.
|
|
function gateAdmin(req: FastifyRequest, reply: FastifyReply): boolean {
|
|
const user = admin(req);
|
|
if ('status' in user) {
|
|
reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Admin only: download the data dir. The database is written to while the
|
|
// archive streams, so it is snapshotted through SQLite's own backup rather than
|
|
// copied — a plain cp could catch a half-committed page. The media folders are
|
|
// append-only and are tarred straight off the volume, so no second copy of them
|
|
// is ever made.
|
|
app.get('/api/admin/backup', async (req, reply) => {
|
|
const user = admin(req);
|
|
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
|
const tmp = mkdtempSync(join(tmpdir(), 'rc-backup-'));
|
|
try {
|
|
await db.backup(join(tmp, 'recipescam.db'));
|
|
} catch (err) {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
req.log.error(err);
|
|
return reply.status(500).send({ error: 'could not snapshot the database' });
|
|
}
|
|
const tar = spawn('tar', ['-czf', '-', '-C', tmp, 'recipescam.db', '-C', DATA_DIR, 'uploads', 'avatars']);
|
|
tar.on('close', () => rmSync(tmp, { recursive: true, force: true }));
|
|
tar.on('error', (err) => {
|
|
req.log.error(err);
|
|
tar.stdout.destroy();
|
|
});
|
|
tar.stderr.on('data', (chunk) => req.log.error(`backup tar: ${String(chunk).trim()}`));
|
|
reply.header('content-type', 'application/gzip');
|
|
reply.header('content-disposition', `attachment; filename="recipescam-backup-${stamp()}.tar.gz"`);
|
|
return reply.send(tar.stdout);
|
|
});
|
|
|
|
// Admin only: put a backup back. The body is the archive itself (see the gzip
|
|
// parser above), and it replaces the data on disk — so the current state is
|
|
// tarred aside first, and the process then exits: `restart: unless-stopped`
|
|
// brings it back up on the restored files, which is the only moment the open
|
|
// SQLite handle can be dropped safely.
|
|
app.post(
|
|
'/api/admin/restore',
|
|
{
|
|
bodyLimit: RESTORE_LIMIT,
|
|
onRequest: (req, reply, done) => {
|
|
if (gateAdmin(req, reply)) done();
|
|
},
|
|
},
|
|
async (req, reply) => {
|
|
const body = req.body as unknown;
|
|
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'empty body' });
|
|
// Unpacked beside the data it is about to replace, not in /tmp: the media
|
|
// folders are hundreds of megabytes, and this keeps the swap on one
|
|
// filesystem. The name is dotted so it can never show up as a media folder.
|
|
const tmp = join(DATA_DIR, `.restore-${Date.now()}`);
|
|
try {
|
|
mkdirSync(tmp, { recursive: true });
|
|
const archive = join(tmp, 'in.tar.gz');
|
|
writeFileSync(archive, body);
|
|
// An archive is untrusted input even when an admin sent it: a `..` entry
|
|
// would write anywhere in the container. The listing is checked before
|
|
// anything is unpacked, and tar itself refuses absolute names.
|
|
const listed = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
if (listed.status !== 0) return reply.status(400).send({ error: 'not a readable .tar.gz' });
|
|
const unsafe = listed.stdout.split('\n').some((name) => name.startsWith('/') || name.split('/').includes('..'));
|
|
if (unsafe) return reply.status(400).send({ error: 'archive has unsafe paths' });
|
|
const unpacked = spawnSync('tar', ['-xzf', archive, '-C', tmp, '--no-same-owner'], {
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (unpacked.status !== 0 || !existsSync(join(tmp, 'recipescam.db')))
|
|
return reply.status(400).send({ error: 'archive has no recipescam.db' });
|
|
// Close before the swap: a checkpoint on close is what makes the safety
|
|
// copy below a complete database rather than one missing its WAL.
|
|
db.close();
|
|
spawnSync('tar', ['-czf', join(DATA_DIR, `.pre-restore-${stamp()}.tar.gz`), '-C', DATA_DIR, ...BACKUP_PATHS], {
|
|
stdio: 'ignore',
|
|
});
|
|
for (const dir of ['uploads', 'avatars']) {
|
|
const from = join(tmp, dir);
|
|
rmSync(join(DATA_DIR, dir), { recursive: true, force: true });
|
|
mkdirSync(join(DATA_DIR, dir), { recursive: true });
|
|
if (existsSync(from)) cpSync(from, join(DATA_DIR, dir), { recursive: true });
|
|
}
|
|
cpSync(join(tmp, 'recipescam.db'), join(DATA_DIR, 'recipescam.db'));
|
|
// Whatever the outgoing database left behind must not be replayed onto the
|
|
// incoming one.
|
|
for (const suffix of ['-wal', '-shm']) rmSync(join(DATA_DIR, `recipescam.db${suffix}`), { force: true });
|
|
setTimeout(() => process.exit(0), 250);
|
|
return reply.status(200).send({ ok: true, restarting: true });
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
app
|
|
.listen({ port: PORT, host: HOST })
|
|
.catch((err) => {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
});
|