1261 lines
57 KiB
TypeScript
1261 lines
57 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,
|
|
setUserPro,
|
|
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: Pick<User, 'email'>) => ADMIN_EMAILS.has(user.email.toLowerCase());
|
|
|
|
// What an account is worth. Being signed in is the basic tier and nothing more
|
|
// is required of it (see requireMember): the account has its own recipes and
|
|
// its own photo folder. This is the proof of address, which the PRO tier still
|
|
// carries on top. 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: Pick<User, 'email' | 'emailVerified'>) => user.emailVerified === 1 || isAdmin(user);
|
|
|
|
// The one date in the tier model: an account that signed up before it keeps the
|
|
// studio it was promised, so its "Activated Pro" box is ticked by the calendar
|
|
// rather than by the operator. Signups from the cutoff on start basic and the
|
|
// box is theirs to tick (or the address to prove).
|
|
const PRO_CUTOFF_MS = Date.parse('2026-12-01T00:00:00Z');
|
|
const grandfathered = (createdAt: string) => Date.parse(createdAt) < PRO_CUTOFF_MS;
|
|
|
|
// The PRO tier itself: a proven address, an admin, a grandfathered signup, or a
|
|
// grant the operator ticked in the users table. The grant only ever adds —
|
|
// unticking an account that has already proven its address leaves it PRO,
|
|
// because the address is still the stronger proof of the two.
|
|
const isPro = (user: Pick<User, 'email' | 'emailVerified' | 'pro' | 'createdAt'>) =>
|
|
isVerified(user) || user.pro === 1 || grandfathered(user.createdAt);
|
|
|
|
// 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 proof of address (the client offers the resend/verify
|
|
// routes off it); `pro` is the studio's own gate, which is that proof or the
|
|
// operator's grant.
|
|
// `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),
|
|
pro: isPro(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`: the basic tier is
|
|
// simply being signed in — recipes, the photo folder, an export of the photo
|
|
// being edited. One refusal, because the studio acts on it the one way: 401
|
|
// sends a guest to the sign-in dialog.
|
|
function requireMember(req: FastifyRequest, reply: FastifyReply): User | undefined {
|
|
const user = auth(req);
|
|
if (!user) {
|
|
void reply.status(401).send({ error: 'unauthorized' });
|
|
return undefined;
|
|
}
|
|
return user;
|
|
}
|
|
|
|
// The PRO tier's own gate, on the routes that are the tier: the geocoder is the
|
|
// one left (every other route that used to take it is the basic tier's now).
|
|
function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined {
|
|
const user = auth(req);
|
|
if (!user) {
|
|
void reply.status(401).send({ error: 'unauthorized' });
|
|
return undefined;
|
|
}
|
|
if (!isPro(user)) {
|
|
void reply.status(403).send({ error: 'pro required' });
|
|
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 `requireMember`: 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 `requireMember`: 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 `requireMember`: 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 = requireMember(req, reply);
|
|
if (!user) return;
|
|
return reply.status(200).send({ recipes: listRecipes(user.id) });
|
|
});
|
|
|
|
app.post('/api/recipes', async (req, reply) => {
|
|
const user = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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 = requireMember(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,
|
|
// The box shows the truth of `isPro`, not the stored column: an
|
|
// allowlisted operator and a signup from before the cutoff are PRO
|
|
// whatever the column says, and unticking them cannot take that away.
|
|
pro: isPro(u),
|
|
})),
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
if (b.pro !== undefined) {
|
|
if (typeof b.pro !== 'boolean') return reply.status(400).send({ error: 'invalid pro' });
|
|
setUserPro(id, b.pro);
|
|
}
|
|
const row = listUsersWithCounts().find((u) => u.id === id);
|
|
return reply
|
|
.status(200)
|
|
.send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt, pro: !!row && isPro(row) } });
|
|
});
|
|
|
|
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);
|
|
});
|