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.

17.2 kB Ruw bestand
import { page, MONO } from "./huisstijl.js";
import {
  MIN_CHARS, LETTERS_PER_HOUR, LETTERS_PER_DAY, NEW_ACCOUNT_LETTERS,
  SEARCH_RESULTS_MAX, PHOTO_MAX_PX, SESSION_TTL_DAYS,
  AGE_MIN, AGE_MAX, FILTER_GAP_MIN, FILTER_GAP_MAX, FILTER_GAP_MID, FILTER_GAP_STEEP,
} from "../src/constants.js";

/*
 * De weetjes: hoe de app werkt, met de techniek erbij. De maatstaf voor elke sectie is of ze een
 * mechanisme uitlegt — een formule, een datastructuur, een valkuil — dat de lezer mee kan nemen
 * naar zijn eigen werk. Wat alleen herhaalt wat de voorpagina of de privacyverklaring al zegt,
 * hoort hier niet: dat is een verkooppagina, en die hebben we al.
 *
 * De getallen komen uit src/constants.js en de formules zijn opgebouwd uit dezelfde constanten
 * die de server gebruikt; de leeftijdsvoorbeelden worden hieronder uitgerekend met dezelfde som
 * als startFilter in server/queries.js. Een pagina die getallen overtypt, klopt op een dag niet.
 */

/*
 * De beginband van het leeftijdsfilter — dezelfde rekensom als OUDSTE/startFilter in
 * server/queries.js (daar in SQL). De ondergrens is de omkering van de bovengrens: de jongste die
 * ik zie is de jongste die mij nog als oudste heeft.
 */
const u = (a) => FILTER_GAP_MIN + (FILTER_GAP_MAX - FILTER_GAP_MIN) / (1 + Math.exp(-(a - FILTER_GAP_MID) / FILTER_GAP_STEEP));
const oudste = (a) => Math.floor(a + u(a));
const klem = (n) => Math.min(AGE_MAX, Math.max(AGE_MIN, n));
const jongste = (a) => {
  for (let b = AGE_MIN; b <= AGE_MAX; b++) if (oudste(b) >= a) return klem(b);
  return AGE_MIN;
};
const band = (a) => `${a} ziet ${jongste(a)}–${klem(oudste(a))}`;

const WEETJES_CSS = `
.formule{font-family:${MONO};font-size:.82em;line-height:1.7;background:var(--panel-2);color:var(--ink);
  padding:10px 14px;margin:0 0 .9em;overflow-x:auto;white-space:nowrap}
`;

export function weetjesHtml() {
  return page(
    "Weetjes over Liefdesbrieven",
    `<h1>Weetjes</h1>
<p class="mute">Hoe de app werkt, met de techniek erbij: de formules, de datastructuren en een
paar valkuilen. Niets hiervan heb je nodig om brieven te schrijven. Het staat er voor wie wil
nakijken wat er met zijn gegevens gebeurt, en voor wie zelf iets bouwt of dat overweegt — neem
gerust mee wat je gebruiken kunt.</p>

<h2>Zoeken: een register achterin het boek</h2>
<p>Profielteksten doorzoeken met <code>tekst bevat 'boek'</code> zou betekenen dat de database bij
elke zoekopdracht álle profielen naleest. In plaats daarvan houdt Postgres een omgekeerde index
bij, zoals het register achterin een boek: per woord de plekken waar het staat. Elke profieltekst
wordt ontleed in woorden en elk woord teruggebracht tot zijn stam — "boeken", "boek" en "boekje"
worden hetzelfde trefwoord, met Nederlandse stamregels. Die kolom is <em>generated</em>: de
database werkt haar zelf bij zodra een profieltekst verandert, er is geen code die het kan
vergeten. Wat jij intypt wordt een prefix-vraag (<code>dost:*</code> vindt Dostojevski), zodat
zoeken-tijdens-het-typen vanzelf werkt.</p>
<p>De echte beslissing zit in de voorwaarde eromheen: één symmetrische WHERE die beide richtingen
toetst — passen jouw filters op de ander, én die van de ander op jou. Je ziet dus alleen mensen
die jou ook zouden zien. En omdat zoeken, brieven versturen en de maandcijfers alle drie diezelfde
ene voorwaarde gebruiken, kan er geen achterdeur bestaan die één van de drie vergeet: wie ergens
onzichtbaar hoort te zijn, is het overal.</p>

<h2>Het leeftijdsfilter is een logistische kromme</h2>
<p>Het startfilter voor leeftijd — wat er staat vóór je zelf aan de schuiven zit — komt uit één
formule, die zegt hoeveel jaar ouder iemand mag zijn dan jij:</p>
<p class="formule">afstand(leeftijd) = ${FILTER_GAP_MIN} + ${FILTER_GAP_MAX - FILTER_GAP_MIN} / (1 + e<sup>−(leeftijd − ${FILTER_GAP_MID}) / ${FILTER_GAP_STEEP}</sup>)</p>
<p>Dat is een logistische functie: een S-vorm die glad van een vloer naar een plafond klimt. De
getallen zijn zo af te lezen: de afstand begint bij ${FILTER_GAP_MIN} jaar (de vloer), klimt naar
${FILTER_GAP_MIN} + ${FILTER_GAP_MAX - FILTER_GAP_MIN} = ${FILTER_GAP_MAX} jaar (het plafond), zit
op ${FILTER_GAP_MID} jaar precies halverwege, en de ${FILTER_GAP_STEEP} bepaalt hoe geleidelijk de
klim is. Bij een 25-jarige begint de bovengrens zo ${Math.round(u(25))} jaar boven de eigen leeftijd, bij
een 50-jarige ${Math.round(u(50))} — een beginstand, geen regel: de schuiven zijn daarna van jou.</p>
<p>De ondergrens is geen tweede formule maar de omkering van de eerste: de jongste die jij ziet is
de jongste die jóu nog binnen zijn eigen bovengrens heeft. Daarmee is de band wederkerig per
constructie — twee mensen op de beginstand zien elkaar allebei, of geen van beiden — en dat moet
ook, want de zoekvoorwaarde hierboven is symmetrisch. In cijfers: ${band(25)}, ${band(33)},
${band(50)}, ${band(80)}.</p>
<p>Waarom niet de bekende vuistregel "de helft plus zeven"? Teken hem en je ziet het: dat is een
rechte lijn, en bij 14 jaar snijdt hij de eigen leeftijd — daar wordt de band nul breed en
daaronder negatief. Zo'n nulpunt is een artefact van de formule, en het dwingt klemmen en
uitzonderingen af om hem te verbergen. De logistische kromme wordt nergens nul, dus die klemmen
bestaan niet.</p>
<p>Nog één ding uit de uitvoering: de omkering staat in SQL als domweg alle leeftijden van
${AGE_MIN} tot ${AGE_MAX} aflopen en de jongste nemen die voldoet. Brute kracht over
${AGE_MAX - AGE_MIN + 1} rijen — prima, want het draait één keer, bij het aanmaken van een
account. Slim hoeft pas als het vaak moet.</p>

<h2>Afstand meten op een bol</h2>
<p>Hebben jullie allebei een locatie ingevuld, dan toont de app de afstand, en een zoekstraal
filtert erop. De locatie zelf wordt afgerond op twee decimalen opgeslagen — ongeveer een
kilometer: genoeg voor een afstand, te grof voor een adres.</p>
<p>Afstand rekenen met coördinaten is minder vanzelfsprekend dan het lijkt, want graden zijn geen
afstanden. Een graad naar het noorden is overal ruwweg 111 km, maar een graad naar het oosten
krimpt richting de polen: op de evenaar 111 km, in Nederland nog ±68. Wie plat Pythagoras op de
coördinaten loslaat, rekent oost-west dus ruim anderhalf keer te veel. De klassieke oplossing is
de haversineformule, die de aarde als bol neemt:</p>
<p class="formule">afstand = 2 · 6371 · asin(√( sin²(Δlat/2) + cos(lat₁) · cos(lat₂) · sin²(Δlon/2) ))</p>
<p>De twee sin²-termen meten hoe ver de punten in elk van beide richtingen uiteenliggen, de
cos-factoren zijn precies de krimp van hierboven, en de asin maal 2 · 6371 (de straal van de aarde
in kilometers) vouwt dat terug tot een afstand over het gebogen oppervlak. In de code is dit een
SQL-functie van zes regels die in de zoekvraag meedraait — er komt geen kaartendienst en geen
GIS-bibliotheek aan te pas, en niemands locatie verlaat er de database voor. Dat de aarde geen
perfecte bol is scheelt hooguit een half procent, en op locaties die al op een kilometer zijn
afgerond is dat niets.</p>

<h2>Wat er in een fotobestand verstopt zit</h2>
<p>Een fotobestand is meer dan pixels. Een JPEG bestaat uit blokken, en één daarvan is EXIF: merk
en type camera, het tijdstip van de opname en — als de camera-app dat mocht — de gps-coördinaten
van waar je stond. Wie een origineel fotobestand doorstuurt, stuurt dat allemaal mee.</p>
<p>Daarom verstuurt de app nooit het origineel. Op het toestel wordt de foto op een canvas
getekend en opnieuw gecodeerd (en verkleind tot ${PHOTO_MAX_PX} pixels op de langste zijde);
alleen de pixels overleven dat, de blokken eromheen niet. De coördinaten verlaten je telefoon dus
nooit — strippen op de server zou te laat zijn, dan zijn ze al over de lijn geweest.</p>
<p>De server vertrouwt op zijn beurt het etiket niet: een bestand dat zégt een JPEG te zijn kan
van alles zijn. Wat niet liegt zijn de eerste bytes — elke JPEG begint met <code>ff d8 ff</code>,
elke PNG met <code>89 50 4e 47</code> — dus de server leest die "magische bytes" en weigert alles
waar etiket en inhoud niet overeenkomen.</p>
<p>Opgeslagen wordt de foto als bytes in de database, naast de brief, in één transactie: de
alles-of-niets-belofte van een database. Brief en foto komen er samen in of geen van beide; een
halve bestaat niet, ook niet als de stroom uitvalt tussen de twee in. En bekijken gaat met het
sessietoken in een header, niet met een code in de URL — URL's slaan neer in logs en
geschiedenissen, headers niet. Wie een foto opvraagt die niet van hem is krijgt "bestaat niet" en
geen "mag niet", want dát een foto bestaat is zelf al informatie over twee mensen.</p>

<h2>Een lettertype is ook een tracking-pixel</h2>
<p>Elk bestand dat een pagina of een e-mail bij een andere server ophaalt, vertelt die server
wanneer en vanaf welk netwerk jij het opende. Zo werkt een tracking-pixel: een doorzichtig plaatje
van één bij één, dat niets toont en alles meldt. Maar een lettertype, een stylesheet of een gewoon
plaatje doet precies hetzelfde — het verschil zit hem niet in het soort bestand, alleen in de
bedoeling. Wie een pagina bouwt met letters van een gratis lettertypedienst, laat elke bezoeker
zich bij die dienst melden zonder dat er ooit iemand toestemming voor gevraagd heeft.</p>
<p>Daarom komen de lettertypen op deze pagina's van dit domein zelf, en gebruikt onze e-mail
helemaal geen eigen letters maar die van jouw toestel — dan valt er niets op te halen. Een mail
die bij het openen iets ophaalt is een leesbevestiging die je niet hebt aangezet, en of jij je post
leest gaat de afzender niet aan.</p>

<h2>Wachtwoorden: traag is de bedoeling</h2>
<p>De server kent je wachtwoord niet. Hij bewaart een hash: de uitkomst van een berekening die
maar één kant op werkt — van wachtwoord naar uitkomst is makkelijk, terug is ondoenlijk. Inloggen
is de berekening herhalen en de uitkomsten vergelijken.</p>
<p>De berekening hier is scrypt, en die is met opzet traag én geheugenvretend. Traag, omdat jij
één poging doet en een aanvaller met een gestolen tabel er miljarden nodig heeft — een halve
seconde per poging voel jij niet en hij wel. Geheugenvretend, omdat videokaarten duizenden
berekeningen tegelijk kunnen doen maar niet duizenden keer zoveel geheugen hebben; dat maakt
massaal raden ook met speciale hardware duur. Elke hash krijgt een eigen salt (willekeurige bytes
erbij), zodat twee mensen met hetzelfde wachtwoord verschillende hashes hebben en voorberekende
tabellen nutteloos zijn. En de parameters staan in de hash zelf mee opgeslagen, zodat ze later
omhoog kunnen zonder dat bestaande accounts breken.</p>
<p>Sessietokens zijn het omgekeerde geval, en het contrast is leerzaam. Een token is 256
willekeurige bits — anders dan een wachtwoord valt er niets aan te raden — dus daar volstaat een
snelle hash (sha256) in de database: wie de tabel steelt heeft niets in handen, en traag hoeft
het niet, want er valt niets door te rekenen.</p>
<p>Tot slot verklapt ook tijd iets. Inloggen met een onbekend e-mailadres zou snel antwoord geven
— er valt immers niets na te rekenen — en aan dat snelheidsverschil zou af te lezen zijn welke
adressen hier een account hebben. Daarom rekent de server óók dan een hash uit: elk antwoord kost
evenveel tijd. Zelfs het vergelijken van twee hashes gebeurt in constante tijd, want een
vergelijking die stopt bij het eerste verschil vertelt hoeveel er al klopte.</p>

<h2>Tellen zonder teller</h2>
<p>Eén account mag ${LETTERS_PER_HOUR} brieven per uur en ${LETTERS_PER_DAY} per etmaal versturen,
en de eerste dag ${NEW_ACCOUNT_LETTERS}: ruim voor een mens die brieven van minstens ${MIN_CHARS}
tekens schrijft, krap voor een script. Het leerzame is hóe er geteld wordt: niet met een teller,
maar uit de brieven zelf — de vraag aan de database is "hoeveel brieven van deze afzender hebben
een verzendtijd in het afgelopen uur?". Zo'n schuivend venster kent geen middernacht waarop een
verse voorraad klaarstaat. En het overleeft elke herstart, wat een teller in het werkgeheugen niet
doet: die springt dan op nul, en dat is precies het moment waarop een script weer los zou mogen.
Het bijproduct is een eerlijke wachttijd — het moment waarop de oudste brief uit het venster valt
is exact het moment waarop het weer kan, en dát staat in het antwoord.</p>
<p>Eén telgrens beschermt tegen kopiëren in plaats van schrijven: een zoekopdracht geeft hoogstens
${SEARCH_RESULTS_MAX} profielen terug. Zonder dat dak is één lege zoekopdracht een kopie van het
complete ledenbestand.</p>

<h2>Zoekwoorden tellen zonder zoekgeschiedenis</h2>
<p>Wat er in de zoekbalk wordt getypt is het waard om geteld te worden: een woord dat vaak gezocht
wordt en zelden in een profieltekst staat, is een gat in de gemeenschap. Maar een zoekgeschiedenis
hoort niet te bestaan. Geteld wordt daarom per los woord en per dag, zonder wie en zonder
tijdstip — "marieke van der berg" valt uiteen in vier woorden die elk op een grote hoop vallen.
Er is niets te exporteren, niets te wissen en niets te vorderen.</p>
<p>Het aardige probleem zit ervóór: de app zoekt terwijl je typt, dus wie "dostojevski" intikt
stuurt onderweg ook "do", "dost" en "dostoje". Naïef tellen meet dan vooral hoe snel iemand typt,
en zet de eerste twee letters van elk woord bovenaan de ranglijst. De server houdt daarom per
zoekende de laatste opdracht een paar seconden vast en telt haar pas als er iets binnenkomt dat
géén voortzetting is (ander woord: de vorige was af), als het even stil blijft (uitgetypt), of als
het zoekveld leeggemaakt wordt. "Voortzetting" is symmetrisch — doortypen en terugtypen zijn
dezelfde zoekopdracht — en de laatste versie wint, want dat is wat er op het scherm stond toen het
zoeken stopte.</p>

<h2>Een melding zonder push</h2>
<p>De gebruikelijke weg voor "je hebt een bericht" is push: de server meldt het bij de
berichtendienst van Google, en die maakt je telefoon wakker. Dat is snel, maar er leest een derde
mee: die dienst weet van elke brief dát hij er is en wanneer. Hier loopt het andersom. Je toestel
vraagt zelf, op de bezorgmomenten die jij instelt, aan de server of er post ligt, en zet daar zijn
eigen melding bij — ingepland achtergrondwerk, dat Android mag uitstellen om de accu te sparen.</p>
<p>De prijs is dat de melding later kan komen: Android bepaalt zelf wanneer achtergrondwerk aan de
beurt is. Voor een chat zou dat onbruikbaar zijn, want een bericht moet er meteen zijn. Een brief
mag een dag oud zijn, dus een uur later maakt niets uit. Alleen daarom kan deze app zonder push —
het product en de techniek passen hier op elkaar.</p>
<p>Eén detail om van te leren: de dagelijkse vraag telt niet als gebruik van de app. Sessies
verlopen na ${SESSION_TTL_DAYS} dagen zonder gebruik, en de maandcijfers tellen wie er actief is;
zou de automatische vraag meetellen, dan verliep er nooit meer een sessie en heette elk slapend
toestel een actief lid. Een meting die het gemetene beïnvloedt, meet uiteindelijk zichzelf.</p>

<h2>Eén DELETE, en alles hangt eraan</h2>
<p>Account verwijderen is in de database één opdracht. Al de rest — sessies, favorieten,
blokkades, brieven, en via de brieven de foto's — hangt er met vreemde sleutels aan vast,
gemarkeerd <code>on delete cascade</code>: wis de gebruiker, en de database wist zelf alles wat
naar hem wijst, ook de brieven bij de mensen die ze kregen. Er is geen opruimscript dat het kan
vergeten en geen tweede opslagplek die kan achterlopen.</p>
<p>Leerzamer is wat er níet cascadeert, want dat is per sleutel een aparte beslissing. Het
betaalbonnetje verliest zijn eigenaar maar blijft staan: verdween het mee, dan leverde "account
wissen en opnieuw beginnen" een gratis tweede plek op. Een melding van misbruik verliest haar
melder maar blijft staan: juist bij misbruik zegt de melder nogal eens zijn account op, en de
melding moet dat overleven. Elke vreemde sleutel in een schema beantwoordt zo dezelfde vraag —
wat hoort er te gebeuren als dit verdwijnt? — en "alles mee weg" is maar één van de goede
antwoorden.</p>

<h2>Waarom de cijfers een maand oud zijn</h2>
<p><a href="/cijfers">De cijfers</a> worden één keer per maand berekend, niet live. Een teller die
meeloopt lekt namelijk: wie hem twee keer afleest, weet wat er tússen die twee momenten gebeurde,
en in een kleine gemeenschap is "er kwam er één bij" al gauw tot een persoon te herleiden. Een
maandstand geeft die twee meetpunten niet. Om dezelfde reden verzwijgt de pagina alles met een
noemer onder de twintig — kleine aantallen wijzen naar mensen.</p>
<p>En één statistiekles die er overal op terugkomt: het gemiddelde liegt hier. Het deelt de post
van de drukste correspondent uit over iedereen die niets kreeg. Daarom staat overal de mediaan
ernaast; beide getallen zijn waar, maar alleen de mediaan beschrijft wat een nieuwkomer treft.</p>

<h2>De broncode is openbaar</h2>
<p>Alles hierboven is na te lezen in de code zelf, met bij elke keuze het waarom in commentaar —
ook waar het eerst misging:</p>
<p class="formule">git clone https://repos.bramluiken.org/git/liefdesbrieven.git</p>
<p>En misschien het nuttigste weetje van allemaal: dit is één server, één database en een telefoon
die netjes vraagt of er post ligt. Een app is geen magie.</p>

<p class="mute mono" style="margin-top:32px"><a href="/">← Liefdesbrieven</a> ·
<a href="/privacy">privacyverklaring</a> · <a href="/cijfers">de cijfers</a> ·
<a href="/kinderveiligheid">kinderveiligheid</a></p>`,
    { css: WEETJES_CSS }
  );
}