Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

47.3 kB Ruw bestand
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { cors } from "hono/cors";
import { bodyLimit } from "hono/body-limit";
import { MIN_CHARS, THREAD_PAGE, GENDER_LABEL, RELATIONSHIP_LABEL, PASSWORD_MIN, PASSWORD_MAX, NAME_MAX, BIO_MAX, LOCATION_DECIMALS, PHOTO_MAX_BYTES, REPORT_REASONS, REPORT_DETAILS_MAX, COMPLAINTS_CONTACT,
         SEARCH_WINDOW_MIN, SEARCH_PER_WINDOW, SIGNUP_PRODUCT_ID, FAREWELL_MAX, AGE_MIN, AGE_MAX } from "../src/constants.js";
import { hashPassword, verifyPassword, newToken, hashToken } from "./auth.js";
import { rateLimiter, clientIp, tooMany, inWords } from "./ratelimit.js";
import { letterDenial } from "./quota.js";
import { verifyPurchase, grantedPurchase } from "./billing.js";
import { privacyHtml, landingHtml, childSafetyHtml } from "./privacy.js";
import { weetjesHtml } from "./weetjes.js";
import { collectReport } from "./report.js";
import { renderPublicHtml, MIN_LEDEN } from "./report-html.js";
import { fontPath } from "./huisstijl.js";
import { readFile } from "node:fs/promises";
import { checkPhoto } from "./media.js";
import { noteSearch } from "./search-log.js";
import promotie, { PROMOTIE_PATH } from "./promotie.js";
import wachtwoord, { stuurHerstelMail, linkBase, RESET_TTL_MS } from "./wachtwoord.js";
import * as db from "./queries.js";

const PORT = Number(process.env.PORT ?? 3000);
/*
 * Toegestane origins. Vite-dev gaat via de proxy (zelfde origin), maar een
 * Capacitor-app praat rechtstreeks: Android = https://localhost, iOS = capacitor://localhost.
 */
const CORS_ORIGINS = (process.env.CORS_ORIGINS ?? "http://localhost:5173,https://localhost,capacitor://localhost,http://localhost")
  .split(",").map((s) => s.trim()).filter(Boolean);
const GENDERS = Object.keys(GENDER_LABEL);
const RELATIONSHIPS = Object.keys(RELATIONSHIP_LABEL);
const REASONS = Object.keys(REPORT_REASONS);

/*
 * Een brief met foto is een JSON-verzoek met base64 erin (~1,35x de bytes); daarboven houdt het op.
 * Nginx weigert grotere verzoeken al eerder (client_max_body_size 2M), dit is de eigen grens.
 */
const MAX_BODY_BYTES = Math.ceil(PHOTO_MAX_BYTES * 1.4) + 64 * 1024;

/*
 * En voor al het andere veel minder. Een brief met foto is het enige zware verzoek dat deze API
 * kent; een profiel, een melding of een inlogpoging is hooguit een paar kilobyte. Hier stond geen
 * enkele grens omheen — alleen `/letters` keek naar de opgegeven Content-Length, en die kan een
 * client weglaten of erover liegen. Wat nginx doorliet (2 MB) werd dus zonder meer ingelezen.
 */
const JSON_MAX_BYTES = 64 * 1024;

/*
 * Drie lagen: `root` is de app op het domein (publieke pagina's: `/`, `/privacy`), daarin gemount
 * `app` = de JSON-API onder `/api` en `media` = de foto's onder `/media`. Nginx proxyt het hele
 * domein hierheen. De Capacitor-app praat alleen met `/api` en `/media`; er draait bewust géén
 * web-versie van de app hier.
 */
/*
 * De toegangslog: alleen het pad, nadrukkelijk zónder querystring. Hono's eigen logger() logt
 * `url.slice(url.indexOf("/", 8))` en neemt de querystring dus wél mee — daarmee stond elke
 * zoekopdracht (`GET /api/search?q=...`) letterlijk in de log, en met de bewaartermijn van 30 dagen
 * erbij is dat een zoekgeschiedenis in een dating-app. Wat we van zoeken willen weten telt
 * search-log.js, per woord en zonder wie. Deze log is voor storingen en beveiliging en hoort dus
 * niet meer te weten dan welk pad wanneer werd opgevraagd en of het lukte.
 *
 * Nginx logt standaard de héle requestregel, mét querystring en mét IP. Zie de README bij
 * "Productie": daar hoort `access_log` de zoekopdrachten ook niet vast te leggen.
 */
const pathLogger = async (c, next) => {
  const start = Date.now();
  const { method } = c.req;
  const path = new URL(c.req.url).pathname;
  await next();
  console.log(`--> ${method} ${path} ${c.res.status} ${Date.now() - start}ms`);
};

const root = new Hono();
root.use(pathLogger);
const app = new Hono();
const media = new Hono();
const crossOrigin = cors({
  origin: CORS_ORIGINS,
  allowHeaders: ["Content-Type", "Authorization"],
  allowMethods: ["GET", "PUT", "POST", "DELETE", "OPTIONS"],
});
app.use(crossOrigin);
media.use(crossOrigin);

/*
 * Het dak op wat er binnenkomt, vóór er ook maar iets ingelezen wordt. Twee maten, want een brief
 * met foto is nu eenmaal duizend keer zo groot als een inlogpoging, en de ruime maat hoort niet te
 * gelden op een pad waar hij nergens voor nodig is.
 */
const grens = (max, melding) => bodyLimit({ maxSize: max, onError: (c) => c.json({ error: melding }, 413) });
const brievenGrens = grens(MAX_BODY_BYTES, "De foto is te groot");
const jsonGrens = grens(JSON_MAX_BYTES, "Dit verzoek is te groot");
app.use((c, next) => (c.req.path.endsWith("/letters") ? brievenGrens : jsonGrens)(c, next));

/*
 * Wat elke pagina van deze server meekrijgt. Er stond niets — geen CSP, geen frame-ancestors, geen
 * Referrer-Policy — terwijl hier een wachtwoordformulier bij zit (/wachtwoord) en een beheerpagina
 * (/promotieteam). Op géén van deze pagina's staat één regel JavaScript, dus `script-src` kan
 * gewoon dicht; alleen de stijl staat inline in een <style>-blok (zie server/huisstijl.js) en in
 * style-attributen, vandaar 'unsafe-inline' voor style en niets anders.
 *
 * Achteraf gezet en alleen wat er nog niet staat: de mediaserver zet zijn eigen, strengere CSP
 * (`default-src 'none'; sandbox`) bij het uitleveren van een foto, en die hoort te winnen.
 */
const VEILIGHEIDSHEADERS = {
  "Content-Security-Policy":
    "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; " +
    "form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
  "Referrer-Policy": "no-referrer",
  "X-Content-Type-Options": "nosniff",
  "X-Frame-Options": "DENY",           // voor wat frame-ancestors nog niet leest
  "Strict-Transport-Security": "max-age=63072000; includeSubDomains",
};
root.use(async (c, next) => {
  await next();
  for (const [naam, waarde] of Object.entries(VEILIGHEIDSHEADERS))
    if (!c.res.headers.has(naam)) c.res.headers.set(naam, waarde);
});

/* ---------- Hulpjes ---------- */

const json = (c) => c.req.json().catch(() => null);
const bearer = (c) => /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "")?.[1] ?? null;
const isEmail = (s) => typeof s === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) && s.length <= 254;
const isIsoDate = (s) => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
const ageOf = (iso) => {
  const b = new Date(iso), t = new Date();
  let a = t.getFullYear() - b.getFullYear();
  if (t.getMonth() < b.getMonth() || (t.getMonth() === b.getMonth() && t.getDate() < b.getDate())) a--;
  return a;
};

/*
 * Een wachtwoord dat gezet wordt: lang genoeg om iets voor te stellen, kort genoeg om te hashen.
 * Geeft een melding of null. De bovengrens staat er niet voor de sierlijkheid — zie PASSWORD_MAX in
 * src/constants.js: elke poging kost scrypt 16 MB en een tiende seconde, ook een mislukte.
 */
const passwordError = (pw) =>
  typeof pw !== "string" || pw.length < PASSWORD_MIN
    ? `Een wachtwoord is minimaal ${PASSWORD_MIN} tekens`
    : pw.length > PASSWORD_MAX
      ? `Een wachtwoord is maximaal ${PASSWORD_MAX} tekens`
      : null;

/*
 * Geboortedatum geldig én binnen AGE_MIN–AGE_MAX? Geeft een foutmelding of null.
 * Dezelfde grenzen als het leeftijdsfilter, en dat is geen toeval: wie zich mag aanmelden moet ook
 * te vinden zijn. Stonden ze los, dan bestaat er een leeftijd die wél een account krijgt en nooit in
 * iemands filter past — en dat is precies wat er misging toen het filter op 99 bleef hangen.
 */
const birthdateError = (birthdate) => {
  if (!isIsoDate(birthdate)) return "Vul een geldige geboortedatum in";
  const age = ageOf(birthdate);
  if (age < AGE_MIN) return `Je moet ${AGE_MIN} jaar of ouder zijn`;
  if (age > AGE_MAX) return "Vul een geldige geboortedatum in";
  return null;
};

/* Wie is dit? Het sessietoken uit de Authorization-header → gebruikers-id, of null */
const sessionUser = async (c) => {
  const token = bearer(c);
  return token ? await db.getSessionUser(hashToken(token)) : null;
};

/* Nieuwe sessie voor userId → { token, user } */
async function startSession(userId) {
  const token = newToken();
  await db.createSession(userId, hashToken(token));
  return { token, user: await db.getMe(userId) };
}

/*
 * Rate-limiting (AVG art. 32): inloggen per adres én per e-mailadres, registreren per adres,
 * en wachtwoordbevestigingen (account wijzigen, account wissen) per gebruiker.
 */
const loginByIp = rateLimiter({ windowMs: 15 * 60_000, max: 20 });
const loginByEmail = rateLimiter({ windowMs: 15 * 60_000, max: 8 });
const registerByIp = rateLimiter({ windowMs: 60 * 60_000, max: 10 });
/* Herstel-links: per adres krap (drie mails per uur is genoeg), per IP iets ruimer */
const forgotByIp = rateLimiter({ windowMs: 60 * 60_000, max: 10 });
const forgotByEmail = rateLimiter({ windowMs: 60 * 60_000, max: 3 });
/*
 * Het nakijken vooraf (POST /auth/check) heeft een eigen, ruimere teller: het hoort bij dezelfde
 * poging als het registreren erna, en anders zou één aanmelding twee plekken uit dezelfde emmer
 * halen. Ruimer, niet ruim — het verklapt net als registreren of een e-mailadres al bestaat.
 */
const checkByIp = rateLimiter({ windowMs: 60 * 60_000, max: 20 });
const confirmByUser = rateLimiter({ windowMs: 15 * 60_000, max: 8 });
/* Melden mag ruim — drempels horen niet bij een meldknop — maar niet zó ruim dat je er iemand mee bestookt */
const reportByUser = rateLimiter({ windowMs: 24 * 60 * 60_000, max: 20 });

/*
 * Zoeken per gebruiker (zie src/constants.js). Hier mág het in het geheugen: verliest de teller bij
 * een herstart, dan is er hoogstens één venster gratis gezocht, en het dak op het aantal resultaten
 * — dát is de maatregel tegen leegtrekken — staat gewoon in de query.
 */
const searchByUser = rateLimiter({ windowMs: SEARCH_WINDOW_MIN * 60_000, max: SEARCH_PER_WINDOW });

/* Hoeveel brieven dit account nu nog mag: de eerste tranche sybil-kosten, zie server/quota.js */

/* ---------- Publieke pagina's (geen sessie, geen JSON): de privacyverklaring en een kale voordeur ---------- */

root.get("/privacy", (c) => c.html(privacyHtml()));
/* Google Play eist deze URL van elke dating-app; zie server/privacy.js */
root.get("/kinderveiligheid", (c) => c.html(childSafetyHtml()));
/* Hoe de app in elkaar zit, voor de nieuwsgierige — zie server/weetjes.js */
root.get("/weetjes", (c) => c.html(weetjesHtml()));
root.get("/", (c) => c.html(landingHtml()));
/* Wachtwoord vergeten: de pagina achter de link uit de herstelmail (zie server/wachtwoord.js) */
root.route("/wachtwoord", wachtwoord);

/*
 * De maandelijkse cijfers. Eén keer per maand uitrekenen en daarna uit het geheugen serveren, en dat
 * is geen zuinigheid maar het ontwerp: een teller die live meeloopt is een teller waaruit je met twee
 * metingen kunt afleiden wat er tussendoor met één persoon gebeurde. Een maandstand kan dat niet.
 * Bij een herstart wordt hij opnieuw berekend — dat mag, het is dezelfde maand.
 *
 * De bereikbaarheidsmeting (O(n²)) blijft eruit: die staat niet op de pagina en zou een publiek
 * verzoek duur maken. `inFlight` zorgt dat twee bezoekers tegelijk hem niet twee keer berekenen.
 */
let cijfers = { tot: 0, html: "" };
let inFlight = null;
root.get("/cijfers", async (c) => {
  if (Date.now() >= cijfers.tot) {
    inFlight ??= collectReport({ reachability: false })
      .then(({ data }) => {
        const nu = new Date();
        /*
         * Is de gemeenschap nog te klein voor cijfers, dan staat er niets op de pagina om af te
         * schermen — en dan is een maand wachten alleen maar hinderlijk: in de koudestartperiode
         * zou er een maand lang "nog te klein" staan terwijl de eerste leden al binnen zijn.
         */
        const tot = data.overview.members < MIN_LEDEN
          ? Date.now() + 60 * 60_000
          : Date.UTC(nu.getUTCFullYear(), nu.getUTCMonth() + 1, 1);
        cijfers = { tot, html: renderPublicHtml(data) };
      })
      .finally(() => { inFlight = null; });
    await inFlight;
  }
  return c.html(cijfers.html);
});

/*
 * Het BIMI-logo: de envelop die mailclients naast post van dit domein zetten, als het TXT-record
 * default._bimi.<domein> hierheen wijst. Ongesigneerd (geen VMC), dus het is niet meer dan dit
 * bestand op een vaste URL. Het is een bron, geen bouwsel — assets/build.mjs schrijft het uit
 * Envelope.jsx binnen het SVG-profiel dat BIMI eist. Het logo verandert vrijwel nooit, maar een
 * jaar cache zou een correctie een jaar lang tegenhouden; een dag is genoeg.
 */
root.get("/bimi.svg", async (c) => {
  try {
    return c.body(await readFile(new URL("../assets/src/bimi.svg", import.meta.url)), 200, {
      "content-type": "image/svg+xml",
      "cache-control": "public, max-age=86400",
    });
  } catch {
    return c.notFound();
  }
});

/*
 * De letters van die pagina, apart in plaats van ingesloten: zo is /cijfers 15 KB in plaats van 170,
 * en haalt een browser ze één keer op. Ze veranderen nooit onder dezelfde naam, vandaar `immutable`.
 * Alleen namen uit FONT_FILES — een pad uit de URL zou hier een lees-alles-maken.
 */
root.get("/lettertype/:naam{[a-z0-9-]+\\.woff2}", async (c) => {
  const p = fontPath(c.req.param("naam").replace(/\.woff2$/, ""));
  if (!p) return c.notFound();
  try {
    const bytes = await readFile(p);
    return c.body(bytes, 200, {
      "content-type": "font/woff2",
      "cache-control": "public, max-age=31536000, immutable",
    });
  } catch {
    return c.notFound();
  }
});
app.get("/privacy", (c) => c.redirect("/privacy", 301)); // oude URL (/api/privacy) blijft werken

/*
 * De ledenlijst van het promotieteam: één afgeschermde beheerpagina, met een eigen wachtwoord en
 * een eigen database. Geen link vanaf de publieke pagina's, en noindex — zie server/promotie.js.
 */
root.get(`${PROMOTIE_PATH}/`, (c) => c.redirect(PROMOTIE_PATH, 301)); // met slash getypt komt ook aan
root.route(PROMOTIE_PATH, promotie);

/* ---------- Mediaserver: de foto bij een brief, op zijn eigen sub-URL ---------- */

/*
 * GET /media/<id> geeft de bytes van één foto — en alleen aan de afzender of de ontvanger van de
 * brief waar hij bij hoort. Daarom hoort dit hier en niet bij de publieke pagina's: het verzoek moet
 * een sessietoken meedragen. Een <img src> kan dat niet, dus de app haalt de foto op met fetch en
 * toont hem als blob-URL (zie src/hooks/usePhoto.js). Zo staat er nooit een toegangsbewijs in een
 * URL, en is er niets te raden: wie de foto niet mag zien, krijgt 404 — dat hij bestaat is zelf al
 * iets wat alleen die twee aangaat.
 */
media.get("/:id", async (c) => {
  const meId = await sessionUser(c);
  if (!meId) return c.text("Niet ingelogd", 401);
  const id = c.req.param("id");
  if (!/^\d+$/.test(id)) return c.text("Niet gevonden", 404);

  const photo = await db.getPhotoFor(meId, id);
  if (!photo) return c.text("Niet gevonden", 404);
  return c.body(photo.bytes, 200, {
    "Content-Type": photo.mime,
    "Content-Length": String(photo.bytes.length),
    // nergens bewaren: niet in een proxy, niet op schijf bij de ontvanger
    "Cache-Control": "private, no-store",
    "X-Content-Type-Options": "nosniff",   // behandel dit als het type dat er staat, raad niet
    "Content-Security-Policy": "default-src 'none'; sandbox",
  });
});

/* ---------- Account: registreren, inloggen, uitloggen ---------- */

/*
 * De velden van een nieuw account nakijken. Geeft { error, status } of { account } met de
 * opgeschoonde waarden — het wachtwoord nog als tekst, want hashen kost een halve seconde en dat
 * hoort pas te gebeuren als er echt een account komt.
 */
async function checkAccount(body) {
  const { email, password, name, gender, trans = false, birthdate, consent } = body;

  if (!isEmail(email)) return { error: "Vul een geldig e-mailadres in" };
  const pwErr = passwordError(password);
  if (pwErr) return { error: pwErr };
  if (typeof name !== "string" || !name.trim() || name.trim().length > NAME_MAX)
    return { error: `Vul een naam in (maximaal ${NAME_MAX} tekens)` };
  if (!GENDERS.includes(gender)) return { error: "Kies wie je bent" };
  if (typeof trans !== "boolean") return { error: "trans moet true of false zijn" };
  const bdErr = birthdateError(birthdate);
  if (bdErr) return { error: bdErr };
  // AVG art. 9 lid 2a: uitdrukkelijke toestemming voor gender/trans/relatiestatus/zoekfilter — geen account zonder
  if (consent !== true) return { error: "Zonder je toestemming voor het verwerken van je gegevens kunnen we geen account aanmaken" };

  const normalizedEmail = email.trim().toLowerCase();
  if (await db.getCredentials(normalizedEmail))
    return { error: "Er bestaat al een account met dit e-mailadres", status: 409 };

  return { account: { email: normalizedEmail, password, name: name.trim(), gender, trans, birthdate } };
}

/*
 * Kost een account op dit moment geld? De app vraagt dit vóór het registratieformulier, zodat hij
 * weet of hij een betaling moet vragen. Alleen ja of nee en welk artikel — geen teller, geen
 * "nog zoveel plekken": wie te laat is hoort niet te zien wat hij gemist heeft, en wie de bak wil
 * leegtrekken hoort niet te kunnen aflezen hoe ver hij is.
 */
app.get("/auth/signup", async (c) => {
  const free = await db.freeSlotOpen();
  return c.json({ free, productId: free ? null : SIGNUP_PRODUCT_ID });
});

/*
 * Het formulier nakijken zonder iets aan te maken. Dit bestaat alleen omdat er betaald wordt: eerst
 * betalen en dán ontdekken dat het e-mailadres al bezet is, is geld kwijt voor niets. De volgorde is
 * dus nakijken → betalen → registreren.
 *
 * Wat er tussen het nakijken en het registreren nog mis kan gaan (iemand anders neemt dat adres in
 * die twintig seconden) blijft mogelijk, maar dan is de aankoop niet verloren: het bonnetje is pas
 * verbruikt als er een account op staat, dus de app kan het opnieuw proberen met dezelfde bon.
 */
app.post("/auth/check", async (c) => {
  const lim = checkByIp.hit(`ip:${clientIp(c)}`);
  if (!lim.ok) return tooMany(c, lim.retryAfterSec);

  const body = await json(c);
  if (!body) return c.json({ error: "Ongeldige JSON" }, 400);

  const checked = await checkAccount(body);
  if (checked.error) return c.json({ error: checked.error }, checked.status ?? 400);
  return c.json({ ok: true });
});

app.post("/auth/register", async (c) => {
  const ip = clientIp(c);
  const lim = registerByIp.hit(`ip:${ip}`);
  if (!lim.ok) return tooMany(c, lim.retryAfterSec);

  const body = await json(c);
  if (!body) return c.json({ error: "Ongeldige JSON" }, 400);

  const checked = await checkAccount(body);
  if (checked.error) return c.json({ error: checked.error }, checked.status ?? 400);

  /*
   * Betaald of gratis. De app stuurt een bonnetje mee als de gratis bak op was; de server gelooft
   * dat bonnetje pas als de store het bevestigt (server/billing.js). Zonder bon gaat het via de
   * gratis bak, en is die net leeg dan zegt de server dat met 402 — waarna de app alsnog om een
   * betaling vraagt. Er is dus geen stand van zaken die de app vooraf moet kennen.
   */
  let purchase = null;
  if (body.purchase != null) {
    const p = body.purchase;
    if (typeof p !== "object" || typeof p.token !== "string" || !p.token.trim())
      return c.json({ error: "Ongeldige aankoop" }, 400);
    const provider = p.provider ?? "google";
    const productId = p.productId ?? SIGNUP_PRODUCT_ID;
    const verified = await verifyPurchase({ provider, token: p.token, productId });
    /* `code` bepaalt of de app het bonnetje bewaart of weggooit — zie verifyPurchase() */
    if (!verified.ok) return c.json({ error: verified.error, code: verified.code }, verified.status);
    purchase = { provider, token: p.token, productId, orderId: verified.orderId };
  }

  const { password, ...account } = checked.account;
  const { user, error } = await db.createAccount({ ...account, passwordHash: await hashPassword(password), purchase });
  if (error === "full")
    return c.json({ error: "De laatste gratis plek is net vergeven. Een account aanmaken kost nu eenmalig geld.", code: "payment_required" }, 402);
  /*
   * 409 betekent hier iets anders dan het 409 hierboven ("dit e-mailadres bestaat al"), en de app
   * moet ze uit elkaar houden: bij een verbruikte bon mag hij die weggooien, bij een bezet
   * e-mailadres juist niet — dan is er betaald en is de bon nog wat waard. Vandaar `code`.
   */
  if (error === "used")
    return c.json({ error: "Op deze aankoop staat al een account. Log in met het e-mailadres dat je toen gebruikte.", code: "purchase_used" }, 409);

  // De plek is uitgegeven: nu pas mag de store horen dat de aankoop verwerkt is (anders draait hij hem terug)
  if (purchase) grantedPurchase(purchase);
  return c.json(await startSession(user.id), 201);
});

app.post("/auth/login", async (c) => {
  const body = await json(c);
  if (!body) return c.json({ error: "Ongeldige JSON" }, 400);
  const { email, password } = body;
  if (typeof email !== "string" || typeof password !== "string") return c.json({ error: "E-mail en wachtwoord ontbreken" }, 400);
  /*
   * Te lang om ooit gezet te kunnen zijn, dus fout — en wel vóór de rate-limiter en vóór scrypt.
   * Dat is precies waar dit dak voor is: het hashen kost 16 MB en een tiende seconde per poging, en
   * hieronder gebeurt dat met opzet ook bij een onbekend adres. Dit verklapt niets over het account
   * dat er niet is: wie dit ziet weet alleen wat hij zelf verstuurde.
   */
  if (password.length > PASSWORD_MAX) return c.json({ error: "E-mailadres of wachtwoord klopt niet" }, 401);

  const normalizedEmail = email.trim().toLowerCase();
  const ipKey = `ip:${clientIp(c)}`, emailKey = `email:${normalizedEmail}`;
  const l1 = loginByIp.hit(ipKey), l2 = loginByEmail.hit(emailKey);
  if (!l1.ok || !l2.ok) return tooMany(c, Math.max(l1.retryAfterSec, l2.retryAfterSec));

  const cred = await db.getCredentials(normalizedEmail);
  // ook bij een onbekend adres een hash controleren, zodat de responstijd niets verraadt
  const ok = cred ? await verifyPassword(password, cred.passwordHash) : (await hashPassword(password), false);
  if (!ok) return c.json({ error: "E-mailadres of wachtwoord klopt niet" }, 401);

  loginByEmail.reset(emailKey); // gelukt: eerdere vergissingen op dit adres tellen niet meer

  /*
   * Geschorst? Dat zeggen we pas ná het wachtwoord — anders is dit endpoint een manier om te
   * achterhalen wie geschorst is. Wél met de reden en de einddatum erbij: een maatregel die je niet
   * uitlegt is geen maatregel maar een storing. 403, niet 401, zodat de client niet stil uitlogt.
   */
  if (cred.suspension) {
    const { until, reason } = cred.suspension;
    const tot = until
      ? `tot ${new Date(until).toLocaleDateString("nl-NL", { day: "numeric", month: "long", year: "numeric" })}`
      : "voor onbepaalde tijd";
    return c.json(
      { error: `Je account is geschorst ${tot}. Reden: ${reason || "overtreding van de huisregels"}. Ben je het er niet mee eens, mail dan ${COMPLAINTS_CONTACT}.` },
      403
    );
  }
  return c.json(await startSession(cred.id));
});

/*
 * Wachtwoord vergeten, het begin: de app geeft een e-mailadres op, en als daar een account bij
 * hoort ligt er zo een herstel-link in de mail (de rest gebeurt op /wachtwoord — zie
 * server/wachtwoord.js). Het antwoord is altijd hetzelfde, en het echte werk is losgekoppeld
 * van het antwoord: ook aan de responstijd is niet af te lezen of het adres bestaat — dezelfde
 * zorg als bij het inloggen hierboven.
 */
app.post("/auth/forgot", async (c) => {
  const body = await json(c);
  if (!body) return c.json({ error: "Ongeldige JSON" }, 400);
  const { email } = body;
  if (typeof email !== "string" || !email.trim()) return c.json({ error: "E-mailadres ontbreekt" }, 400);
  const normalizedEmail = email.trim().toLowerCase();

  const l1 = forgotByIp.hit(`ip:${clientIp(c)}`), l2 = forgotByEmail.hit(`email:${normalizedEmail}`);
  if (!l1.ok || !l2.ok) return tooMany(c, Math.max(l1.retryAfterSec, l2.retryAfterSec));

  /* Waar de link heen wijst staat vast en komt niet uit dit verzoek — zie server/wachtwoord.js */
  const base = linkBase();
  const wie = await db.getAccountByEmail(normalizedEmail);
  if (wie) {
    (async () => {
      const token = newToken();
      await db.createPasswordReset(wie.id, hashToken(token), new Date(Date.now() + RESET_TTL_MS));
      await stuurHerstelMail({ to: normalizedEmail, name: wie.name, link: `${base}/wachtwoord?token=${token}` });
    })().catch((err) => console.error("herstel-link:", err.message));
  }
  return c.json({ ok: true });
});

/*
 * "Ligt er post?" — het enige verzoek dat het toestel doet terwijl de app dicht is (zie README,
 * *Bericht als er post ligt*). Staat met opzet vóór de middleware hieronder en controleert de
 * sessie zelf: die middleware werkt last_seen_at bij, en een bezorgcheck is geen gebruik van de
 * app. Zou hij meetellen, dan verliep er nooit meer een sessie en heette elk slapend toestel een
 * actief lid. Daarom `getSessionUserQuiet`: wel wie je bent, geen spoor dat je er was.
 *
 * ?since=<briefId> = de hoogste brief waarover dit toestel al een melding gaf (standaard 0).
 * Antwoord: { senders, name, latest } — zie db.unreadSince. Bij 401 (uitgelogd, verlopen of
 * geschorst) stopt het toestel met kijken tot er weer ingelogd wordt.
 */
app.get("/post", async (c) => {
  const token = bearer(c);
  const meId = token ? await db.getSessionUserQuiet(hashToken(token)) : null;
  if (!meId) return c.json({ error: "Niet ingelogd" }, 401);
  const since = c.req.query("since") ?? "0";
  if (!/^\d+$/.test(since)) return c.json({ error: "Ongeldige since" }, 400);
  return c.json(await db.unreadSince(meId, since));
});

/*
 * Vanaf hier is een sessie verplicht: Authorization: Bearer <token>.
 * Zonder (geldig) token → 401; de client wist dan zijn token en toont het inlogscherm.
 */
app.use(async (c, next) => {
  const meId = await sessionUser(c);
  if (!meId) return c.json({ error: "Niet ingelogd" }, 401);
  c.set("meId", meId);
  c.set("tokenHash", hashToken(bearer(c)));
  await next();
});

app.post("/auth/logout", async (c) => {
  await db.deleteSession(c.get("tokenHash"));
  return c.json({ ok: true });
});

/* ---------- Mijn profiel ---------- */

/* Mijn profiel, met een eventuele openstaande waarschuwing erbij — de app zet die vóór alles */
app.get("/me", async (c) => {
  const meId = c.get("meId");
  const [me, warning] = await Promise.all([db.getMe(meId), db.getPendingWarning(meId)]);
  return me ? c.json({ ...me, warning }) : c.json({ error: "Gebruiker niet gevonden" }, 404);
});

/* Waarschuwing gelezen en weggeklikt; alleen je eigen, en maar één keer */
app.post("/warnings/:id/ack", async (c) => {
  const id = c.req.param("id");
  if (!/^\d+$/.test(id)) return c.json({ error: "Ongeldig id" }, 400);
  const ok = await db.acknowledgeWarning(c.get("meId"), id);
  return ok ? c.json({ acknowledged: true }) : c.json({ error: "Waarschuwing niet gevonden" }, 404);
});

app.put("/me", async (c) => {
  const body = await json(c);
  if (!body) return c.json({ error: "Ongeldige JSON" }, 400);

  const { text, relationship, genders, seekRelatie = false, seekTrans = false, ageMin, ageMax, location = null, radiusKm = null, hidden = false } = body;
  if (typeof text !== "string") return c.json({ error: "text ontbreekt" }, 400);
  /* De profieltekst wordt geïndexeerd en komt in elk zoekresultaat mee — zie BIO_MAX */
  if (text.length > BIO_MAX) return c.json({ error: `Een profieltekst is maximaal ${BIO_MAX} tekens` }, 400);
  if (typeof hidden !== "boolean") return c.json({ error: "hidden moet true of false zijn" }, 400);
  if (!RELATIONSHIPS.includes(relationship))
    return c.json({ error: `relationship moet één van ${RELATIONSHIPS.join(", ")} zijn` }, 400);
  if (!Array.isArray(genders) || !genders.every((g) => GENDERS.includes(g)))
    return c.json({ error: `genders moet een lijst zijn uit ${GENDERS.join(", ")}` }, 400);
  if (typeof seekRelatie !== "boolean") return c.json({ error: "seekRelatie moet true of false zijn" }, 400);
  if (typeof seekTrans !== "boolean") return c.json({ error: "seekTrans moet true of false zijn" }, 400);
  const inRange = (n) => Number.isInteger(n) && n >= AGE_MIN && n <= AGE_MAX;
  if (!inRange(ageMin) || !inRange(ageMax)) return c.json({ error: `ageMin/ageMax moeten ${AGE_MIN}–${AGE_MAX} zijn` }, 400);
  /*
   * En in die volgorde. De app sorteert ze al (ProfileEditor), maar zonder deze regel komt een
   * omgekeerd bereik ongezien bij `check (seek_min_birth <= seek_max_birth)` uit, en dat is een 500
   * op iets wat gewoon foute invoer is.
   */
  if (ageMin > ageMax) return c.json({ error: "ageMin mag niet groter zijn dan ageMax" }, 400);

  // locatie + straal zijn optioneel; een straal heeft een locatie nodig
  if (location !== null) {
    const { lat, lon } = location ?? {};
    if (!(Number.isFinite(lat) && lat >= -90 && lat <= 90 && Number.isFinite(lon) && lon >= -180 && lon <= 180))
      return c.json({ error: "location moet { lat: -90..90, lon: -180..180 } of null zijn" }, 400);
  }
  if (radiusKm !== null) {
    if (!(Number.isInteger(radiusKm) && radiusKm > 0 && radiusKm <= 20000))
      return c.json({ error: "radiusKm moet een geheel getal > 0 zijn, of null" }, 400);
    if (location === null) return c.json({ error: "radiusKm vereist een location" }, 400);
  }

  // dataminimalisatie: locatie afgerond opslaan (~1 km) — genoeg voor afstand, geen adres
  const round = (n) => Math.round(n * 10 ** LOCATION_DECIMALS) / 10 ** LOCATION_DECIMALS;
  const rounded = location && { lat: round(location.lat), lon: round(location.lon) };

  const me = await db.updateProfile(c.get("meId"), { text, relationship, genders, seekRelatie, seekTrans, ageMin, ageMax, location: rounded, radiusKm, hidden });
  return me ? c.json(me) : c.json({ error: "Gebruiker niet gevonden" }, 404);
});

/* ---------- AVG: toestemming, rectificatie, inzage en vergetelheid ---------- */

/* Bestaand account geeft alsnog (of opnieuw) toestemming voor de gevoelige gegevens: { consent: true } */
app.post("/me/consent", async (c) => {
  const body = await json(c);
  if (!body || body.consent !== true) return c.json({ error: "consent moet true zijn" }, 400);
  const me = await db.giveConsent(c.get("meId"));
  return me ? c.json(me) : c.json({ error: "Gebruiker niet gevonden" }, 404);
});

/*
 * Accountgegevens wijzigen (rectificatie): { currentPassword, name?, email?, birthdate?, gender?, trans?, newPassword? }.
 * Het huidige wachtwoord is altijd verplicht (dit zijn de inloggegevens en de identiteit); een nieuw wachtwoord
 * logt alle andere toestellen uit. Weggelaten velden blijven zoals ze zijn. Fout wachtwoord → 403 (geen 401, zie DELETE).
 */
app.put("/account", async (c) => {
  const meId = c.get("meId");
  const body = await json(c);
  if (!body || typeof body.currentPassword !== "string") return c.json({ error: "Bevestig met je huidige wachtwoord" }, 400);
  const { currentPassword, name, email, birthdate, gender, trans, newPassword } = body;

  const patch = {};
  if (name !== undefined) {
    if (typeof name !== "string" || !name.trim() || name.trim().length > NAME_MAX)
      return c.json({ error: `Vul een naam in (maximaal ${NAME_MAX} tekens)` }, 400);
    patch.name = name.trim();
  }
  if (email !== undefined) {
    if (!isEmail(email)) return c.json({ error: "Vul een geldig e-mailadres in" }, 400);
    patch.email = email.trim().toLowerCase();
  }
  if (birthdate !== undefined) {
    const err = birthdateError(birthdate);
    if (err) return c.json({ error: err }, 400);
    patch.birthdate = birthdate;
  }
  if (gender !== undefined) {
    if (!GENDERS.includes(gender)) return c.json({ error: "Kies wie je bent" }, 400);
    patch.gender = gender;
  }
  if (trans !== undefined) {
    if (typeof trans !== "boolean") return c.json({ error: "trans moet true of false zijn" }, 400);
    patch.trans = trans;
  }
  if (newPassword !== undefined) {
    const err = passwordError(newPassword);
    if (err) return c.json({ error: err }, 400);
  }
  /* Ook het huidige wachtwoord gaat langs scrypt, dus ook dat heeft een dak nodig */
  if (currentPassword.length > PASSWORD_MAX) return c.json({ error: "Huidig wachtwoord klopt niet" }, 403);
  if (!Object.keys(patch).length && newPassword === undefined) return c.json({ error: "Niets te wijzigen" }, 400);

  const lim = confirmByUser.hit(`user:${meId}`);
  if (!lim.ok) return tooMany(c, lim.retryAfterSec);
  const hash = await db.getPasswordHash(meId);
  if (!hash) return c.json({ error: "Gebruiker niet gevonden" }, 404);
  if (!(await verifyPassword(currentPassword, hash))) return c.json({ error: "Huidig wachtwoord klopt niet" }, 403);
  confirmByUser.reset(`user:${meId}`);

  if (patch.email) {
    const other = await db.getCredentials(patch.email);
    if (other && other.id !== meId) return c.json({ error: "Er bestaat al een account met dit e-mailadres" }, 409);
  }
  const me = await db.updateAccount(meId, {
    ...patch,
    passwordHash: newPassword !== undefined ? await hashPassword(newPassword) : undefined,
    keepTokenHash: c.get("tokenHash"),
  });
  return me ? c.json(me) : c.json({ error: "Gebruiker niet gevonden" }, 404);
});

/*
 * Mijn andere toestellen uitloggen: { currentPassword } → { loggedOut: <aantal> }. Dit toestel
 * blijft ingelogd.
 *
 * Bestaat omdat een sessie alleen glijdend verloopt (SESSION_TTL_DAYS ná het laatste gebruik): een
 * toestel dat blijft meelezen verloopt dus nooit vanzelf. De export toont die sessies al; hiermee
 * kun je er ook iets aan doen zonder je wachtwoord te hoeven veranderen.
 *
 * Het wachtwoord is verplicht, en dat is de hele beveiliging van deze knop: zonder zou een gestolen
 * token genoeg zijn om de eigenaar zelf buiten te zetten. 403 bij een fout wachtwoord, geen 401 —
 * daarop wist de client zijn token (zie DELETE /me).
 */
app.post("/me/logout-others", async (c) => {
  const meId = c.get("meId");
  const body = await json(c);
  if (!body || typeof body.currentPassword !== "string") return c.json({ error: "Bevestig met je huidige wachtwoord" }, 400);
  if (body.currentPassword.length > PASSWORD_MAX) return c.json({ error: "Huidig wachtwoord klopt niet" }, 403);

  const lim = confirmByUser.hit(`user:${meId}`);
  if (!lim.ok) return tooMany(c, lim.retryAfterSec);
  const hash = await db.getPasswordHash(meId);
  if (!hash) return c.json({ error: "Gebruiker niet gevonden" }, 404);
  if (!(await verifyPassword(body.currentPassword, hash))) return c.json({ error: "Huidig wachtwoord klopt niet" }, 403);
  confirmByUser.reset(`user:${meId}`);

  return c.json({ loggedOut: await db.deleteOtherSessions(meId, c.get("tokenHash")) });
});

/* Alles wat we van mij bewaren, als JSON (inzage/overdraagbaarheid) */
app.get("/me/export", async (c) => {
  const data = await db.exportUser(c.get("meId"), c.get("tokenHash"));
  return data ? c.json(data) : c.json({ error: "Gebruiker niet gevonden" }, 404);
});

/*
 * Account definitief wissen: { password } ter bevestiging. Alles gaat mee (profiel, sessies,
 * favorieten, blokkades en álle brieven, ook bij de correspondenten). Een verkeerd wachtwoord
 * geeft 403 — géén 401, want daarop wist de client zijn token en logt hij de gebruiker uit.
 *
 * `farewell` is optioneel: de brief aan de beheerder die iemand bij zijn vertrek achterlaat. Hij
 * gaat in dezelfde transactie mee als het wissen, en zonder wie hem schreef (zie `farewells` in
 * db/schema.sql). Een brief die te lang is laat het wissen niet mislukken — dat zou de verkeerde
 * kant op falen: vertrekken is een recht, de brief is een gunst — hij wordt gewoon afgekapt.
 */
app.delete("/me", async (c) => {
  const meId = c.get("meId");
  const body = await json(c);
  if (!body || typeof body.password !== "string") return c.json({ error: "Bevestig met je wachtwoord" }, 400);
  if (body.password.length > PASSWORD_MAX) return c.json({ error: "Wachtwoord klopt niet" }, 403);
  const farewell = typeof body.farewell === "string" ? body.farewell.trim().slice(0, FAREWELL_MAX) : "";
  const lim = confirmByUser.hit(`user:${meId}`);
  if (!lim.ok) return tooMany(c, lim.retryAfterSec);
  const hash = await db.getPasswordHash(meId);
  if (!hash) return c.json({ error: "Gebruiker niet gevonden" }, 404);
  if (!(await verifyPassword(body.password, hash))) return c.json({ error: "Wachtwoord klopt niet" }, 403);
  await db.deleteUser(meId, { farewell });
  /* Alleen dát er post ligt; wat erin staat hoort in de database en niet in journald (30 dagen, breder leesbaar) */
  if (farewell) console.log("afscheidsbrief: er ligt een brief van iemand die vertrokken is (npm run reports -- --afscheid)");
  return c.json({ deleted: true });
});

/* ---------- Zoeken (wederzijds + fulltext) ---------- */

app.get("/search", async (c) => {
  const meId = c.get("meId");
  const lim = searchByUser.hit(`user:${meId}`);
  if (!lim.ok) return tooMany(c, lim.retryAfterSec, `Je hebt in korte tijd heel veel gezocht. Zoeken kan weer ${inWords(lim.retryAfterSec)}.`);
  const q = c.req.query("q") ?? "";
  /* Tellen wat er gezocht wordt, zonder wie: één regel per zoekintentie, niet per toetsaanslag.
     Bewust niet afgewacht — het antwoord op deze zoekopdracht hangt er niet van af. */
  noteSearch(meId, q);
  return c.json(await db.searchProfiles(meId, q));
});

/* ---------- Postvak ---------- */

app.get("/threads", async (c) => {
  return c.json(await db.listThreads(c.get("meId")));
});

/* Eén pagina brieven: ?before=<letterId> voor oudere, ?limit=<n> (standaard THREAD_PAGE, max 100) */
app.get("/threads/:userId", async (c) => {
  const other = c.req.param("userId");
  if (!/^\d+$/.test(other)) return c.json({ error: "Ongeldig userId" }, 400);
  const before = c.req.query("before") ?? null;
  if (before !== null && !/^\d+$/.test(before)) return c.json({ error: "Ongeldige before" }, 400);
  const limitRaw = c.req.query("limit");
  const limit = limitRaw == null ? THREAD_PAGE : Number(limitRaw);
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) return c.json({ error: "limit moet 1–100 zijn" }, 400);
  const thread = await db.getThread(c.get("meId"), other, { before, limit });
  return thread ? c.json(thread) : c.json({ error: "Gebruiker niet gevonden" }, 404);
});

/*
 * Zoeken door één correspondentie: ?q=<woorden> geeft de passende brieven, nieuwste eerst, met de
 * gevonden woorden gemarkeerd. Geen rem eromheen zoals bij /search: dit is je eigen post, er valt
 * niets prijs te geven — het dak zit in de query (THREAD_HITS_MAX).
 */
app.get("/threads/:userId/search", async (c) => {
  const other = c.req.param("userId");
  if (!/^\d+$/.test(other)) return c.json({ error: "Ongeldig userId" }, 400);
  return c.json(await db.searchThread(c.get("meId"), other, c.req.query("q") ?? ""));
});

/* Brieven van deze persoon aan mij als gelezen markeren (de client roept dit aan zodra de correspondentie open staat) */
app.post("/threads/:userId/read", async (c) => {
  const other = c.req.param("userId");
  if (!/^\d+$/.test(other)) return c.json({ error: "Ongeldig userId" }, 400);
  return c.json({ marked: await db.markThreadRead(c.get("meId"), other) });
});

/*
 * Blokkeren of deblokkeren: { block: true | false }. Blokkeren alleen bij een lopende correspondentie;
 * deblokkeren altijd. Stil voor de ander (die ziet alleen dat post niet meer bezorgd kan worden).
 */
app.post("/threads/:userId/block", async (c) => {
  const meId = c.get("meId");
  const other = c.req.param("userId");
  if (!/^\d+$/.test(other)) return c.json({ error: "Ongeldig userId" }, 400);
  const body = await json(c);
  if (!body || typeof body.block !== "boolean") return c.json({ error: "block moet true of false zijn" }, 400);
  if (other === meId) return c.json({ error: "Je kunt jezelf niet blokkeren" }, 400);
  if (body.block && !(await db.hasThread(meId, other)))
    return c.json({ error: "Je hebt geen correspondentie met deze persoon" }, 404);
  await db.setBlock(meId, other, body.block);
  return c.json({ block: body.block });
});

/* Wie ik geblokkeerd heb: [{ user, since }] */
app.get("/blocks", async (c) => c.json(await db.listBlocked(c.get("meId"))));

/* ---------- Melden (misbruik) ---------- */

/*
 * Een persoon of één brief melden bij de beheerder: { about, letterId?, reason, details?, block? }.
 *
 * Anders dan blokkeren mag dit óók zonder lopende correspondentie: een profiel in Zoeken kan op
 * zichzelf al misbruik zijn (nep, minderjarig). Meteen blokkeren erbij (`block: true`) kan alleen
 * als er post tussen jullie ligt — de app biedt het daarom alleen daar aan; ligt er niets, dan
 * gaat de melding gewoon door en zegt het antwoord `blocked: false`.
 *
 * Wat er terugkomt is niet meer dan een bevestiging: of de melding terecht was, en wat ermee
 * gebeurt, hoort de gemelde niet via een API-antwoord te kunnen aflezen.
 */
app.post("/reports", async (c) => {
  const meId = c.get("meId");
  const body = await json(c);
  if (!body) return c.json({ error: "Ongeldige JSON" }, 400);
  const { about, letterId = null, reason, details = "", block = false } = body;

  if (typeof about !== "string" || !/^\d+$/.test(about)) return c.json({ error: "about ontbreekt" }, 400);
  if (about === meId) return c.json({ error: "Je kunt jezelf niet melden" }, 400);
  if (!REASONS.includes(reason)) return c.json({ error: "Kies waarom je meldt" }, 400);
  if (typeof details !== "string" || details.length > REPORT_DETAILS_MAX)
    return c.json({ error: `Een toelichting is maximaal ${REPORT_DETAILS_MAX} tekens` }, 400);
  if (letterId !== null && (typeof letterId !== "string" || !/^\d+$/.test(letterId)))
    return c.json({ error: "Ongeldige letterId" }, 400);
  if (typeof block !== "boolean") return c.json({ error: "block moet true of false zijn" }, 400);

  const lim = reportByUser.hit(`user:${meId}`);
  if (!lim.ok) return tooMany(c, lim.retryAfterSec);

  const reported = await db.getUser(about);
  if (!reported) return c.json({ error: "Gebruiker niet gevonden" }, 404);

  const report = await db.createReport({ reporterId: meId, reportedId: about, letterId, reason, details: details.trim() });
  if (!report) return c.json({ error: "Die brief hoort niet bij deze correspondentie" }, 400);

  // blokkeren blijft dezelfde regel volgen als de blokkeerknop: alleen bij een lopende correspondentie
  const blocked = block && (await db.hasThread(meId, about));
  if (blocked) await db.setBlock(meId, about, true);

  /*
   * In het log alleen ids, reden en het totaal — géén toelichting en géén brieftekst: die staan in
   * de database en horen niet in journald, dat maar 30 dagen bewaart en breder leesbaar is.
   * `melding` is het woord om op te grepen; afhandelen gaat met `npm run reports`.
   */
  console.warn(
    `melding #${report.id}: gebruiker ${meId} meldt gebruiker ${about} (${reason})` +
      `${letterId ? `, brief ${letterId}` : ""} — ${report.total}e melding over deze persoon`
  );

  return c.json({ id: report.id, blocked }, 201);
});

/* Correspondent als favoriet markeren (of niet meer): { favorite: true | false }. Alleen bij een lopende correspondentie */
app.post("/threads/:userId/favorite", async (c) => {
  const meId = c.get("meId");
  const other = c.req.param("userId");
  if (!/^\d+$/.test(other)) return c.json({ error: "Ongeldig userId" }, 400);
  const body = await json(c);
  if (!body || typeof body.favorite !== "boolean") return c.json({ error: "favorite moet true of false zijn" }, 400);
  if (other === meId || !(await db.hasThread(meId, other)))
    return c.json({ error: "Je hebt geen correspondentie met deze persoon" }, 404);
  await db.setFavorite(meId, other, body.favorite);
  return c.json({ favorite: body.favorite });
});

/* ---------- Brief versturen ---------- */

app.post("/letters", async (c) => {
  const meId = c.get("meId");
  // vóór het inlezen: een verzoek dat sowieso te groot is, hoeft niet eerst helemaal binnengehaald
  const declared = Number(c.req.header("content-length") ?? 0);
  if (declared > MAX_BODY_BYTES) return c.json({ error: "De foto is te groot" }, 413);
  const body = await json(c);
  if (!body) return c.json({ error: "Ongeldige JSON" }, 400);

  const { to, text, photo = null } = body;
  if (typeof to !== "string" || !/^\d+$/.test(to)) return c.json({ error: "to ontbreekt" }, 400);
  if (to === meId) return c.json({ error: "Je kunt jezelf geen brief sturen" }, 400);
  if (typeof text !== "string" || text.length < MIN_CHARS)
    return c.json({ error: `Een brief is minimaal ${MIN_CHARS} tekens` }, 400);

  /*
   * Hoeveel brieven mag dit account nu nog? Vóór het nakijken van de foto: een geweigerde brief
   * hoeft geen megabyte base64 te kosten. Geteld uit de verstuurde brieven zelf (db.letterQuota),
   * dus een herstart van de server geeft niemand een nieuwe voorraad.
   */
  const quota = await db.letterQuota(meId);
  if (!quota) return c.json({ error: "Gebruiker niet gevonden" }, 404);
  const denial = letterDenial(quota);
  if (denial) return tooMany(c, denial.retryAfterSec, denial.error);

  // { data (base64), mime, width, height, alt } — of null. Zie server/media.js
  let checked = null;
  if (photo !== null) {
    /*
     * Een openingsbrief gaat zonder foto. Pas als de ander mij geschreven heeft mag er beeld mee —
     * ongevraagde post is dus altijd tekst, ook de tweede en de derde brief aan iemand die zwijgt.
     * Vóór checkPhoto: dan hoeft die base64 niet eens gedecodeerd te worden.
     */
    if (!(await db.hasLetterFrom(meId, to)))
      return c.json({ error: "In een eerste brief gaat nog geen foto mee. Dat kan zodra de ander je geschreven heeft." }, 400);
    const result = checkPhoto(photo);
    if (result.error) return c.json({ error: result.error }, 400);
    checked = result.photo;
  }

  /*
   * Een blokkade (in welke richting ook) of een schorsing gaat vóór alles: dezelfde neutrale
   * melding, geen reden — dat de ander geschorst is gaat de schrijver niet aan.
   *
   * De naam staat er alleen bij als er post tussen jullie ligt, want dan ken je die al. Hem er
   * altijd bij zetten maakte dit endpoint een manier om bij een willekeurig id een naam op te
   * halen: `to` is niet meer dan een getal dat de verzender zelf kiest.
   */
  if ((await db.isBlockedEither(meId, to)) || (await db.isSuspended(to))) {
    const other = (await db.hasThread(meId, to)) ? await db.getUser(to) : null;
    return c.json({ error: other
      ? `Post aan ${other.name} kan niet meer bezorgd worden`
      : "Deze post kan niet meer bezorgd worden" }, 403);
  }
  /*
   * Geen match en geen lopende post. De reden staat er met opzet niet bij: dat kan zijn dat de
   * filters niet wederzijds passen, maar ook dat de ander zich verborgen heeft of zijn profieltekst
   * heeft weggehaald — en dan zou de melding precies verklappen wat verbergen moet voorkomen.
   */
  if (!(await db.canWrite(meId, to)))
    return c.json({ error: "Je kunt deze persoon nu geen brief sturen" }, 403);

  const letter = await db.sendLetter(meId, { to, text, photo: checked });
  return c.json(letter, 201);
});

/* ---------- Foutafhandeling ---------- */

root.route("/api", app);
root.route("/media", media);
root.notFound((c) => (c.req.path.startsWith("/api") ? c.json({ error: "Niet gevonden" }, 404) : c.text("Niet gevonden", 404)));
root.onError((err, c) => {
  console.error(err);
  return c.req.path.startsWith("/api") ? c.json({ error: "Interne fout" }, 500) : c.text("Interne fout", 500);
});

/* ---------- Bewaartermijnen: dagelijks verlopen sessies en slapende accounts opruimen ---------- */

const PURGE_EVERY_MS = 24 * 60 * 60_000;
async function purge() {
  try {
    const { sessions, users, reports } = await db.purgeExpired();
    if (sessions || users || reports)
      console.log(`opschoning: ${sessions} verlopen sessies, ${users} slapende accounts, ${reports} oude meldingen gewist`);
  } catch (err) {
    console.error("opschoning mislukt:", err);
  }
}
purge();
setInterval(purge, PURGE_EVERY_MS).unref();

serve({ fetch: root.fetch, port: PORT }, (info) => {
  console.log(`Liefdesbrieven API luistert op http://localhost:${info.port}/api`);
});