Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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`);
});