Weetjes

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.

Zoeken: een register achterin het boek

Profielteksten doorzoeken met tekst bevat 'boek' 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 generated: 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 (dost:* vindt Dostojevski), zodat zoeken-tijdens-het-typen vanzelf werkt.

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.

Het leeftijdsfilter is een logistische kromme

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:

afstand(leeftijd) = 2 + 11 / (1 + e−(leeftijd − 24) / 5)

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 2 jaar (de vloer), klimt naar 2 + 11 = 13 jaar (het plafond), zit op 24 jaar precies halverwege, en de 5 bepaalt hoe geleidelijk de klim is. Bij een 25-jarige begint de bovengrens zo 8 jaar boven de eigen leeftijd, bij een 50-jarige 13 — een beginstand, geen regel: de schuiven zijn daarna van jou.

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: 25 ziet 20–33, 33 ziet 25–44, 50 ziet 38–62, 80 ziet 68–92.

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.

Nog één ding uit de uitvoering: de omkering staat in SQL als domweg alle leeftijden van 18 tot 120 aflopen en de jongste nemen die voldoet. Brute kracht over 103 rijen — prima, want het draait één keer, bij het aanmaken van een account. Slim hoeft pas als het vaak moet.

Afstand meten op een bol

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.

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:

afstand = 2 · 6371 · asin(√( sin²(Δlat/2) + cos(lat₁) · cos(lat₂) · sin²(Δlon/2) ))

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.

Wat er in een fotobestand verstopt zit

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.

Daarom verstuurt de app nooit het origineel. Op het toestel wordt de foto op een canvas getekend en opnieuw gecodeerd (en verkleind tot 1600 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.

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 ff d8 ff, elke PNG met 89 50 4e 47 — dus de server leest die "magische bytes" en weigert alles waar etiket en inhoud niet overeenkomen.

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.

Een lettertype is ook een tracking-pixel

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.

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.

Wachtwoorden: traag is de bedoeling

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.

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.

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.

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.

Tellen zonder teller

Eén account mag 8 brieven per uur en 24 per etmaal versturen, en de eerste dag 4: ruim voor een mens die brieven van minstens 300 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.

Eén telgrens beschermt tegen kopiëren in plaats van schrijven: een zoekopdracht geeft hoogstens 100 profielen terug. Zonder dat dak is één lege zoekopdracht een kopie van het complete ledenbestand.

Zoekwoorden tellen zonder zoekgeschiedenis

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.

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.

Een melding zonder push

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.

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.

Eén detail om van te leren: de dagelijkse vraag telt niet als gebruik van de app. Sessies verlopen na 90 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.

Eén DELETE, en alles hangt eraan

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 on delete cascade: 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.

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.

Waarom de cijfers een maand oud zijn

De cijfers 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.

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.

De broncode is openbaar

Alles hierboven is na te lezen in de code zelf, met bij elke keuze het waarom in commentaar — ook waar het eerst misging:

git clone https://repos.bramluiken.org/git/liefdesbrieven.git

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.

← Liefdesbrieven · privacyverklaring · de cijfers · kinderveiligheid