Compare commits

..
42 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 5e670119db feat(notes): titlen er første linje, og # foreslår projektet
Release / Build and publish image (push) Failing after 9m59s
Titlen var udledt én gang og derefter ladt i fred, så den gik i stå i det øjeblik
nogen skrev begyndelsen om — en note med overskriften "Ny note" stod i listen som
"fdgfgfgh", hvilket var, hvad dens første linje havde været nogle rettelser
tidligere. Den udledes nu ved hver gemning, og ingen kan sætte den ved siden af
teksten: kolonnen er en kopi af kroppen, ikke et felt derudover. Feltet er væk fra
både HTTP-fladen og MCP-værktøjerne, for et felt, serveren ignorerer, er et løfte,
den ikke kan holde.

Skriver man #Kaf, foreslås #Kaffebaren. Ordet læses fra den tekstknude, markøren
står i, og ikke fra hele dokumentet — en note kan nævne tyve projekter, og kun det
under markøren er ved at blive skrevet. Listen ejer piletasterne og retur, mens
den er åben, som enhver anden fuldførelse.

Og under feltet står nu, hvad teksten forstår: # projekt, [[en anden note]], ⌘B,
⌘U. Samme idé som linjen under hurtig tilføjelse — en note er det andet sted i
programmet, hvor det, man skriver, bliver læst for betydning.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:36:31 +02:00
Kristian WindandClaude Opus 5 3223989a9d feat(notes): første linje er titlen, resten er brødtekst
Release / Build and publish image (push) Failing after 9m57s
En ny note åbner på sin titel, og linjen efter er brødtekst — uden at man vælger
noget. Det er det, Apple Notes gør, og det er også hvad listen har brug for:
første linje bliver notens navn, så en note, der begynder i brødtekst, hedder sin
egen første sætning.

To ting undervejs. Reserven for en tom note blev aldrig brugt, fordi en tom tekst
ikke gengives som ingenting men som ét tomt afsnit — den prøves nu på teksten og
ikke på resultatet. Og Chromium svarer på retur med et bart <div>, som læses som
brødtekst men ikke er formateret som det: afstanden mellem afsnit forsvandt
stille i det øjeblik, man trykkede retur frem for at vælge Brødtekst i menuen.

Listen viste desuden den rå Markdown i forhåndsvisningen — "# Møde med Anders" og
"**fed**" — hvilket er præcis det, editoren blev bygget for at holde op med at
vise. Den viser nu sætningen frem for notationen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:23:42 +02:00
Kristian WindandClaude Opus 5 f557d319ca feat(sidebar): de faste visninger kan sorteres, og rækkefølgen følger kontoen
Release / Build and publish image (push) Failing after 9m57s
I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have
dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om
skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken
rækkefølge man vil have dem i, er en kendsgerning om personen.

En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og
manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at
strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige
forlod menuen.

To ting, prøverne fandt undervejs, og begge var mine:

Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede
modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi
rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det.
Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet
var en menuflytning.

Migration 0019.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:19:46 +02:00
Kristian WindandClaude Opus 5 90bdf4b725 feat(look): en akse mere — hvordan det læses, ikke hvor lyst det er
Fire udseender ved siden af de fem temaer: Verdande, Rolig (serif, mere luft),
Tæt (flere rækker, mindre luft) og Terminal (én bredde til det hele). De ganges ud
til tyve udseender fra otte små blokke CSS frem for at kræve en blok hver.

To akser, fordi det er to spørgsmål. Den, der vil have en serif om natten, skal
ikke opgive den mørke palet for at få den — og prøven holder netop det fast: at
vælge en skrift må ikke trække temaet med sig.

Udseendet sættes før første optegning, ligesom temaet. Ellers bliver siden sat op
i én skrift og derefter i en anden, og det er et synligt spring.

Det er også det ærlige svar på at ligne noget andet. Standarden er bevidst
genkendelig — det var det, der blev bedt om — men et program med ét muligt
udseende har lånt en form, det ikke kan give tilbage. Med udseender er ligheden en
indstilling blandt flere frem for det eneste, det kan være.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:07:39 +02:00
Kristian WindandClaude Opus 5 1bbae698d5 feat(notes): en note deles ved at ligge i et projekt
Ikke en adgangsliste af sin egen. Projekter har allerede medlemmer, roller og
invitationer — alt sammen afprøvet, og alt sammen noget folk har lært her i
forvejen. En anden måde at give adgang på er et andet sted at gøre det forkert,
og de to ville være uenige den dag, den ene blev ændret.

To ting, prøven fandt, som begge var rigtige fejl.

Projektsiden viste kun noter, der NÆVNTE projektet, ikke dem, der lå i det — så
en note, man lige havde delt derind, dukkede ikke op. Nu vises begge slags.

Og noteoversigten viste kun dem uden projekt, så en note forsvandt fra listen i
det øjeblik, den blev delt. Det fik deling til at se ud som sletning. Listen viser
nu alt, man kan se: sine egne løse, og dem i de projekter, man er med i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:03:28 +02:00
Kristian WindandClaude Opus 5 1b02eecdab feat(notes): rich text, med Apple Notes' egne stilarter
Man skriver nu i formateret tekst frem for i Markdown-kildekode. Titel,
Overskrift, Underrubrik, Brødtekst, Monotype, Blokcitat, punkt- og talliste, plus
fed, kursiv, understreget og gennemstreget — samme ordforråd som Apple Notes, for
det er det, en, der kommer derfra, allerede kan.

Filen under er stadig Markdown. Det er ikke et kompromis, nogen skal leve med i
stilhed: det er grunden til, at eksporten er til at stole på — filen, en note
bliver til, er den fil, den allerede var. Understregning er den ene undtagelse;
den findes ikke i noget Markdown-dialekt og gemmes som <u>, som enhver læser
sender uændret igennem. Det er bedre end at fjerne en stilart, kilden har.

contenteditable med execCommand. Den er forældet og også det eneste, alle browsere
er enige om; at skrive en markeringsmodel fra bunden er ugers arbejde og en ny
slags fejl netop dér, hvor tabt tekst er utilgiveligt.

Tre ting, prøverne fandt, som alle ville have kostet tekst:
Værktøjslinjen stjal markeringen, når man klikkede — knapperne tager ikke fokus
længere. Browseren skrev <span style="font-weight: normal"> for en ophævet fed,
hvilket en oversætter, der læser tags, taber; der bedes nu om tags, og styles
læses alligevel, fordi det ikke honoreres alle steder. Og markeringen med
Shift+Home tog hele dokumentet, ikke linjen.

Favoritter er stjernen på hver note. Noteoversigten har fået luft mod venstre
kant, og Gem og Slet er knapper frem for bare ord.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 16:58:53 +02:00
Kristian WindandClaude Opus 5 5757f06e7a feat(notes): man kan se hvad man skriver
Release / Build and publish image (push) Failing after 10m0s
Formatering vises, mens man skriver. Overskrifter, fed, kursiv, kode, citater,
punkter, #tags og [[links]].

Et spejl bag et gennemsigtigt tekstfelt — samme greb som hurtig tilføjelse bruger
til at vise, hvad parseren forstod. En contenteditable ville lade teksten style
direkte og ville også ødelægge fortryd, autokorrektur og IME. Det er en dårlig
byttehandel i det ene sted i programmet, hvor nogen skriver en halv time i træk.

Mærkerne bliver stående, dæmpede. Obsidian skjuler dem uden for den linje,
markøren står i; at dæmpe dem altid er en mindre idé, der ikke flytter tekst under
markøren mens den bevæger sig — og den overlever mødet med et tekstfelt.

Overskrifter skifter vægt og farve, aldrig størrelse: en større skrift ville fylde
flere linjer end tekstfeltet gør, og så skred alt nedenunder.

Hele risikoen ved grebet er, at de to lag skal flugte tegn for tegn. Prøven måler
det frem for at stole på det: skrifttype, linjehøjde, ombrydning, polstring og den
faktiske højde af de to lag med samme tekst i.

Undervejs: kursiv-passet slugte " og " efter et fed-udtryk, fordi den løse stjerne
stadig var synlig for det. Hvert pas læser nu en kopi, hvor de tidligere pas'
tegn er blændet ud.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:38:18 +02:00
Kristian WindandClaude Opus 5 5f04fe56bc feat(notes): note-værktøjer i MCP, og sidebjælken ryddet op
Claude kan nu skrive og søge i noter gennem MCP. Det er også svaret på Apple
Noter: der findes ingen eksport, der er værd at bruge, men en model, der kører på
maskinen, kan læse Notes.app og skrive det, den finder, ind herigennem — én note
ad gangen, med teksten som Markdown. En flytning, ingen skal bygge en parser til.

Værktøjet melder tilbage, hvilke links noten fik. En note, hvis #tag var stavet
forkert, ser ellers præcis ud som en, hvor det ramte, og i en chat ser ingen den
række, der blev skrevet.

Noter står nu for sig selv med en streg over. De er ikke en femte måde at se de
samme opgaver på, hvilket er hvad alt ovenfor er — de er den anden halvdel af
programmet, og i den række læste de som endnu et filter.

Færdige er ude af menuen og ligger under Indstillinger → Data ved siden af
papirkurven. Argumentet for at have den fremme var, at man ikke ved, hvilken
visning man lukkede noget i — det er sandt, og det er også et sjældent ærinde ved
siden af fire punkter, der læses hver dag. Den er der stadig, med en beskrivelse
af hvad den er til.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:33:54 +02:00
Kristian WindandClaude Opus 5 a2a39c4e17 feat(notes): en flade at skrive i, og noten dukker op i projektet
Release / Build and publish image (push) Failing after 9m55s
Tredje etape. En liste til venstre, noten til højre — ikke et gitter af kort: en
note læses langt oftere på sin første linje end i sin helhed, og en liste viser
tyve af dem, hvor et gitter viser seks.

Gemmes når man holder op med at skrive. En note er det ene sted i programmet, hvor
en times skrivning kan gå tabt på ét fejltryk, og en Gem-knap er en knap, der
findes for at blive glemt. Teksten holdes adskilt fra den note, den kom fra, så en
gemning, der lander sent, ikke kan overskrive det, der er skrevet siden.

Og udbetalingen: skriver man #GarageRisteriet i en note, står den på projektets
side. Ingen har filet den derhen — den dukker op, fordi nogen nævnte projektet
mens de skrev. Slået op på navnet og ikke på et id, for et #tag skrives i hånden,
og et menneske skriver navnet.

Markdown i et almindeligt tekstfelt indtil videre. Live preview, hvor mærkerne kun
vises i den linje markøren står i, er næste stykke og det dyre; det her er den
form, den skal sidde inde i, og alt andet er allerede sandt uden den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:07:49 +02:00
Kristian WindandClaude Opus 5 fbfc98e217 feat(notes): noter kan nås — listet, søgt og fundet baglæns
Anden etape. Handlere, ruter og OpenAPI.

Adgang følger projektet, for det er der, noten bor: en note i et delt projekt kan
læses af alle, der kan læse projektet, og ændres af en redaktør. En note uden
projekt er sin forfatters alene — samme svar som en opgave i indbakken får, nået
frem til på samme måde frem for ved en regel, noter fandt på for sig selv.

Listning og søgning er samme endepunkt, fordi de svarer på det samme spørgsmål
med forskellige ord. Filtreringen sker EFTER indekset: FTS5 svarer om tekst, og
at lære indekset om roller ville lægge en adgangsregel et sted, ingen ville kigge
den dag den var forkert. Der er en prøve for, at en fremmeds søgning ikke finder
noget, og at en fremmed får 404 og ikke 403 — så et id ikke kan afprøves.

Den baglæns rute hænger på det, der spørges om: et opgave-id giver hver note, der
nævner den; et projektnavn giver hver note med det #tag.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:00:41 +02:00
Kristian WindandClaude Opus 5 98ea08b0bb feat(notes): fundamentet — Markdown, søgning og links begge veje
Release / Build and publish image (push) Failing after 10m3s
Første etape af noter. Tabel, CRUD, fuldtekstsøgning og de links, en note bærer.

Markdown i en kolonne, ikke rich text og ikke et format af vores eget. Eksport
var et krav, før der var skrevet en linje, og er den gemte form den eksporterede
form, er der ingen konvertering tilbage at tabe noget i — noten kan læses af
enhver anden editor den dag programmet ikke findes.

Et #tag i en note er DET SAMME tag som i en opgave. Ikke en anden slags, der
ligner. Det er hele svaret på "tag en note og se den i projektet": referencen er
den samme, så der ikke opstår to systemer, brugeren selv skal holde i sync.

Links gemmes som rækker, udledt af teksten ved hver gemning. Teksten er stadig
sandheden; tabellen er et indeks over den. Uden det ville "hvad peger på denne
opgave" betyde en gennemsøgning af al prosa, og den rude ville være for langsom
til at lade stå åben. De rives ned og bygges op frem for at blive sammenlignet:
en sammenligning er en anden beskrivelse af samme kendsgerning, og den kan komme
til at være uenig med teksten.

Noter ligger under projekter og arver deling, roller, papirkurv og historik. En
mappestruktur ved siden af ville være ét hierarki for meget.

MatchExpr fik navngivne kolonner. Den var skrevet til opgavernes og gav
"no such column" mod noternes — hvilket i det mindste er højlydt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:47:50 +02:00
Kristian WindandClaude Opus 5 c44099cac0 feat(site): vælg lys eller mørk, og skærmbilledet følger med
Siden fulgte systemet og kunne ikke andet. Nu er der en knap, valget huskes, og
skærmbilledet øverst skifter med — der ligger både en mørk og en lys udgave af
det samme, og det er kejtet at vise et mørkt skærmbillede på en lys side.

Farverne står to gange med vilje. Mediaforespørgslen er hvad en førstegangsbesøgende
får; attributten er hvad en, der har valgt, får — og den skal vinde begge veje, både
et valgt lyst tema på et mørkt system og omvendt. Én regel kan ikke gøre begge dele.

Begge skærmbilleder ligger i markuppen og skjules med CSS, så ingen af dem skal
hentes i det øjeblik nogen trykker på knappen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:36:11 +02:00
Kristian WindandClaude Opus 5 86ec0ce2d9 fix(imap): markøren er vores, ikke serverens
Samme mail blev en opgave hver tiende minut, og markøren stod stille på 1271.
Målt i produktion: en manuel synk lavede én opgave og flyttede ikke last_uid en
eneste plads.

Årsagen er ikke vores regnestykke, men hvad søgningen svarer. Vi beder om
1272:* — og en rigtig server læser det som 0:1272 og sender hele postkassen
tilbage. Alt, hvad den finder, ligger så under markøren, og markøren kan derfor
aldrig komme videre.

Uid'er ved eller under markøren frasorteres nu hos os. Det koster ét gennemløb
af nogle hundrede tal og afhænger ikke af nogen andens læsning af specifikationen.

De to tidligere forsøg var ikke forkerte, de var bare ikke årsagen: at tage
uid'en fra søgningen frem for fra fetch-svaret, og at give hver postkasse sin
egen lås, var begge reelle fejl. Denne er den, der fik den til at gentage sig.

Prøven læser en postkasse med markøren over alt i den og kræver nul tilbage —
og gentager det for hver enkelt besked.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:33:30 +02:00
Kristian WindandClaude Opus 5 62498c2765 feat(sidebar): tallet ved et projekt er opgaver, og folden har en tast
"Hvorfor står der 2 ud for et tomt projekt?" Fordi tallet var member_count — hvor
mange mennesker. Den samme grå kolonne betød tre ting: personer ved projekter,
projekter ved grupper, opgaver ved etiketter. I en opgaveapp læses et tal ved
siden af et projekt som opgaver, og det gjorde manden, der har skrevet appen.

Nu er det åbne opgaver, og det vises når der er noget tilbage frem for når
projektet er delt — som var den gamle betingelse, og grunden til at netop de tre
delte havde et tal. Hvem der ellers er med står på projektets egen side.

Tællingen hentes fra serveren igen, når opgaver flytter sig. Det ville være
hurtigere at lægge til og trække fra lokalt, og det ville være forkert inden for
en dag: en opgave flyttet mellem projekter, en lukket i en anden fane, en
gentagende der lukker og åbner sig selv.

Og sidebjælken kunne allerede foldes — der var bare kun en knap med et rektangel
med en streg i, og det er ikke et ord. Nu gør ⌘B det samme, knappen siger det, og
genvejssiden nævner det.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:28:43 +02:00
Kristian WindandClaude Opus 5 997dffe01c docs: skriv det, der blev bygget, og ret det, der ikke passer længere
To nye sider. Postkasser: at stjerne i Gmail eller flage i iCloud og Fastmail
bliver til opgaver, at hver postkasse hører til den, der forbandt den, at
app-kodeord er noget andet end ens rigtige, og at Office 365 ikke kan nås ad den
vej — sidstnævnte skrevet ned frem for at blive opdaget ved at prøve. Og at logge
ind: kodeord, totrinskode og passkeys, med hvorfor en passkey ikke kan laves på
en bar IP-adresse.

Konfigurationen manglede VERDANDE_SECRET_KEY, som er den, det koster mest ikke at
vide noget om: mister man nøglen, skal postkasserne forbindes forfra. Begrundelsen
står med — det er kopierne, ikke serveren, der er problemet, for de kan hentes
gennem fladen.

Og genvejsafsnittet i quick-add beskrev q, t og u, som ikke findes længere. En
dokumentation, der lover taster, der ikke gør noget, er værre end ingen: man
prøver, der sker intet, og så tror man appen er i stykker.

Bygget med --strict; alle krydshenvisninger rammer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:25:54 +02:00
Kristian WindandClaude Opus 5 d02d8edfe9 feat(site): klik et skærmbillede og se det i læsbar størrelse
Release / Build and publish image (push) Failing after 10m5s
Et skærmbillede af en brugerflade siger ingenting ved 281 pixels, og det er der,
de fleste af dem sidder i gitteret. Et klik åbner nu det samme billede omkring
tre en halv gang større.

<dialog> frem for et selvbygget lag: Escape lukker, fokus bliver inde i den,
resten af siden bliver inert — tre ting browseren gør gratis og som et selvbygget
overlay skal have kode for hver af. Billedet får rolle og tabindex, så det også
kan åbnes fra tastaturet; en kontrol, kun en mus kan nå, er ikke en kontrol alle
har.

To ting rettet undervejs, som først kunne ses ved at kigge: billedet fik lov at
blive højere end skærmen, så billedtekst og lukkeknap røg op over kanten — og
lukkeknappen er den synlige vej ud. Og dialogen fyldte hele bredden i stedet for
at slutte om billedet, så skærmbilledet stod op ad venstre kant med knappen ude i
det modsatte hjørne. Nu er den fit-content og centreret, og alt er inden for
vinduet ned til en telefonbredde.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:14:13 +02:00
Kristian WindandClaude Opus 5 134fe8918e chore: hold overleveringen uden for repoet
Den navngiver panelet, server-id'et og produktionsadressen — et kort over én
persons opsætning, og det hører ikke til i et offentligt repo. Ingen adgangskoder,
men et server-id og en panel-adresse er præcis det, et lækket token skal bruge for
at være farligt.

Filen er fjernet fra hele historikken, ikke kun fra spidsen: at slette den i en ny
commit ville have efterladt den læselig i alle de gamle. Den ligger stadig på
disken, for det er sådan en session afleverer til den næste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:08:08 +02:00
Kristian WindandClaude Opus 5 3fd16f71df fix(reminders): en påmindelse, ingen modtager, er ikke en påmindelse
Påmindelser blev sendt i appen og på mail. Har man fanen lukket og ingen
mailserver sat op — hvilket er præcis det, docker-linjen i dokumentationen giver
— kom der ingenting, mens indstillingssiden sagde at notifikationer var slået til
for enheden.

Push-halvdelen var aldrig hægtet på. Notifikationer fra andres handlinger gik ud;
kun de tidsstyrede gjorde ikke, og det er dem, man har slået til for at slippe for
at holde øje selv. Jobbet har nu en vej til at sende, leveret af HTTP-laget, som
ejer VAPID-nøglerne og listen over enheder — samme mønster som synkroniseringerne.

Prøven ser efter, at den når frem, og at den kun når frem én gang: en påmindelse,
der ankommer ved hver fejning, er værre end en, der ikke ankommer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:00:48 +02:00
Kristian WindandClaude Opus 5 37605ba684 feat(site): landingssiden siger hvad der findes, og vejer en femtedel
Skærmbillederne lå som PNG i fuld skærmopløsning — 2,1 MB for syv billeder på en
side, der viser dem under 900 pixels bred. Som WebP i dobbelt visningsbredde
fylder de 424 kB. Samme billeder, samme skarphed, en femtedel af vægten.

Indholdet fangede aldrig op på det, der blev bygget: postkasser over IMAP ved
siden af Gmail, passkeys, at man kan skrive hvor som helst for at fange noget, at
hemmeligheder ligger krypteret med nøglen uden for databasen, og historikken.
Ni felter nu, hvilket også fylder gitteret ud i stedet for at efterlade et tomt.

Og en fejl, jeg selv lavede undervejs: jeg lagde en description-meta ind, fordi
min søgning ikke så den, der lå i forvejen — den var delt over flere linjer.
Dubletten er væk, og den oprindelige er opdateret i stedet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 10:52:20 +02:00
Kristian WindandClaude Opus 5 6e941f9928 fix(mailboxes): to kørsler af samme postkasse må ikke læse den samme markør
Fyrre kopier af én mail ankom i samme sekund. Én kørsel kan højst lave femogtyve,
så der var to i gang: et tryk på "Hent nu" mens ti-minutters-fejningen allerede
var inde i den samme postkasse. Begge læste markøren som den stod, begge hentede
de samme beskeder, begge lavede opgaver ud af dem.

Markør-rettelsen fra i går var altså rigtig, men ikke nok — den virker, når der
kun er én læser. Rækken læses nu igen under låsen, og låsen holdes hele vejen
igennem: slap man den, før markøren var skrevet, ville hullet stå åbent præcis
der, hvor det skulle lukkes.

Et kort pr. postkasse frem for én lås. To menneskers postkasser har intet at sige
hinanden, og én fælles lås ville gøre én langsom vært til alles problem.

Prøven kører to kørsler oven i hinanden med vilje og tæller, hvor mange der er
inde ad gangen — en lås er kun værd at have, hvis noget prøver døren. Den holder
også øje med, at to forskellige postkasser ikke venter på hinanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 08:57:59 +02:00
Kristian WindandClaude Opus 5 993822f6fd fix(panel): bed om en planlagt genstart, ikke en der dør med afsenderen
Release / Build and publish image (push) Failing after 10m6s
Knappen tog produktionen ned og lod den ligge. Panelets almindelige restart
udfører arbejdet mens forespørgslen står åben: den stopper containeren — hvilket
slår denne proces ihjel — og panelet, der ser afsenderen forsvinde, når aldrig
til den halvdel, der starter igen. Fire minutters nedetid den 18. august, og det
kan ikke lappes herfra: enhver knap, der synkront beder om sin egen død, har
samme form.

safe-restart planlægges i stedet. Panelet svarer "Restart scheduled" og gør
arbejdet bagefter for egen regning, så intet afhænger af, at denne proces stadig
er i live til at høre svaret.

Prøven hang bagefter, og det var prøvens skyld: dens falske panel læste aldrig
forespørgslens krop, og der er en nu. En server, der ikke læser hvad den fik,
opdager aldrig at afsenderen har lagt på — så httptest ventede på en handler, der
aldrig vendte tilbage, og hele binæren løb tør for tid i stedet for den ene
forespørgsel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 23:15:10 +02:00
Kristian WindandClaude Opus 5 b0f78d1549 feat(gmail): Gmail flytter ind i postkasse-tabellen
Ét sted at spørge "hvilke postkasser har den her person". Gmails tokens, udløser,
etiket, set-liste og målprojekt ligger nu som en række i mailboxes ved siden af
dem, der læses over IMAP, og baggrundsjobbet fejer én gang i stedet for to og
skifter vej efter rækkens kind.

PKCE-værdierne bliver i user_settings. De hører til ét forsøg på at logge ind,
ikke til en postkasse, og de skal væk igen når forsøget er ovre.

Målprojektet fik sin egen kolonne, som IMAP-postkasser også får glæde af — en
arbejdspostkasse, der filer i arbejdsprojektet, er det åbenlyse at ville have.
Ikke en fremmednøgle: sletter man projektet, skal destinationen stille holde op
med at gælde, ikke afvise sletningen eller tage postkassen med sig.

Flytningen kunne først køre nu. Den tømmer indstillingsrækken, og havde den ligget
i v0.14.0, mens handlerne stadig læste den, var en fungerende postkasse blevet
koblet fra. Værdierne kopieres som chiffertekst — begge tabeller forsegler med
samme nøgle — så migrationen behøver ikke nøglen, hvilket en migration heller
ikke har noget at gøre med at holde.

Der er en prøve for præcis dét: en Gmail forbundet før flytningen, med et forseglet
token skrevet som indstillingstabellen skrev dem, kommer ud på den anden side med
token, udløser, adresse, målprojekt og set-liste i behold.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:59:40 +02:00
Kristian WindandClaude Opus 5 f3fecbd0b4 fix(imap): samme mail blev en ny opgave hver gang, og uddraget var rå MIME
To fejl fra ét skærmbillede.

Markøren stod stille. Den blev sat ud fra uid'en i fetch-svaret, og den kommer
ikke fra alle servere — så next-run læste fra nul hver gang. Den sættes nu ud fra
hvad SØGNINGEN fandt, når kørslen kom hele vejen igennem: så er alt op til det
punkt betragtet, uanset hvad fetch-svaret bar med. En afbrudt kørsel flytter kun
til den sidste, den faktisk lavede en opgave af. Prøven mod hukommelsesserveren
ville aldrig have fanget det, for den svarer altid med uid.

Og uddraget hentede BODY[TEXT], som ved multipart er grænser og dele-hoveder og
det hele — derfor stod der "--=_be71cb39… Content-Transfer-Encoding: 8bit" foran
teksten i hver eneste opgave. Nu hentes hele beskeden og MIME-strukturen gås
igennem: text/plain foretrækkes, HTML er reserven med mærkerne pillet af. Groft,
for det er linjen under en overskrift og ikke en gengivelsesmotor.

Multipart-prøven er sat på prøve: med den gamle hentning fejler den med præcis
den tekst, skærmbilledet viste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:44:00 +02:00
Kristian WindandClaude Opus 5 30adc69cb5 feat(mailboxes): plads til Gmails udløser og dens set-liste
Kolonnerne, ikke flytningen. Gmail-handlerne læser stadig user_settings, og en
migration, der tømte den tabel før de var skrevet om, ville koble en fungerende
postkasse fra. Det additive først; flytningen når koden på den anden side er klar
til den, og den ligger som PENDING_gmail_move.sql.txt uden for .sql-mønstret,
så den ikke kan køre ved et uheld.

Udløseren fik sin egen kolonne frem for at blive pakket ind i label med et kolon.
En kolonne, der rummer to ting adskilt af et tegn, er en kolonne, nogen læser
forkert senere — og en etiket med kolon i navnet ville gøre det på første dag.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:38:45 +02:00
Kristian WindandClaude Opus 5 c9dfffd95a feat(mailboxes): postkasser kan forbindes fra fladen
Release / Build and publish image (push) Failing after 9m59s
Panelet under Integrationer: tilføj, hent nu, kobl fra. Sit eget frem for en
tilstand mere inde i Gmail-panelet, for de to er ikke det samme i forskellige
klæder — det ene er en app, man logger ind igennem, det andet et kodeord, man
selv holder.

Feltet hedder IMAP-server og ikke Server: der stod i forvejen Server på CalDAV
længere nede, og to felter med samme navn på én side er tvetydige også for en
skærmlæser. Testen fandt det, men det var ikke kun testens problem.

To vagter fangede mangler undervejs, og begge havde ret: mailbox_failed skulle
have en tekst i fladen, ellers ville nogen få serverens engelske logsprog at se,
og de tre ruter skulle i openapi.yaml. Der er nu et Mailbox-skema samme sted, som
siger hvad der ikke kommer tilbage — kodeordet og eventuelle tokens.

E2E-prøven forbinder til noget, der ikke lytter, og ser efter værtens egen
afvisning på skærmen. Den er samtidig prøven på at 422 var det rigtige valg: var
det stadig 502, ville et mellemled kunne kaste beskeden væk, og der ville stå
"noget gik galt".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:22:31 +02:00
Kristian WindandClaude Opus 5 6b4ef5b2f2 feat(imap): postkasser kan læses, og det er bevist mod en rigtig server
Handlere til at forbinde, koble fra og hente nu, plus baggrundssynk hvert tiende
minut ved siden af Gmails. Forbindelsen prøves FØR den gemmes: en postkasse, der
blev taget imod og så fejler stille hvert tiende minut, er værre end en, der blev
afvist ved døren, mens man stadig sidder med kodeordet i hånden.

Ingen "set"-liste til deduplikering som Gmail har. Uid'er er monotone, så
last_uid er hele svaret — og den skrives også, når budgettet løb ud, for en
kørsel, der lavede opgaver og glemte hvor langt den nåede, laver dem alle igen
næste gang.

Rundturen køres nu mod go-imap's egen server i processen: flaget post kommer med,
uflaget gør ikke, og fortsættelsen fra et uid henter kun det nyere. De hidtidige
prøver dækkede de rene dele og ville alle have bestået med selve hentningen i
stykker.

Testen kræver et selvsigneret certifikat, og klienten nægter dem. Account.RootCAs
lader en test TILFØJE tillid; der er stadig ingen måde at fjerne den på. Forskellen
er en søm og ikke et hul — en prøve viser, at samme server afvises uden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:15:50 +02:00
Kristian WindandClaude Opus 5 1e59886d86 feat(mailboxes): en tabel, så I kan have hver sin postkasse — og flere hver
Gmail lå i user_settings under en scope. Den tabel har user_id som hele
primærnøglen, så den rummer præcis én scope pr. person, og dens upsert skriver
scope-navnet om ved konflikt: at lægge IMAP ved siden af ville have slettet
Gmail-forbindelsen uden at sige det. Migration 0011 advarer ordret mod netop det,
og advarslen var tæt på at blive gået i for anden gang.

Formen var forkert alligevel. Man har postkasser, flertal — en arbejds-Gmail og
en privat iCloud er det almindelige tilfælde, ikke det sære. En række hver siger
det. Og den svarer på spørgsmålet, der satte det i gang: hver postkasse hører til
en bruger, så to mennesker på samme instans forbinder hver sin og ser kun sin
egen. Det eneste, der bliver fælles, er Googles OAuth-klient, og det er rigtigt:
den er appen, ikke kontoen.

Kodeord og tokens skrives forseglet. Forseglingen i notifications.go dækker kun
indstillingstabellerne, så denne tabels metoder kalder den i hånden — værd at
vide, før der kommer en tredje hemmelig kolonne.

Gmail bliver hvor den er indtil videre. Den virker, og at lægge en flytning af en
kørende forbindelse i samme ombæring som en ny integration gør begge dele sværere
at efterprøve. Skemaet har allerede plads til den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:10:20 +02:00
Kristian WindandClaude Opus 5 cbe66d75a5 feat(capture): skriv hvor som helst, og se hvad feltet forstår
Release / Build and publish image (push) Failing after 10m4s
To ting fra Doda. Begynder man at skrive et sted i appen, åbner hurtig tilføjelse
med bogstavet allerede i feltet — ingen knap at ramme først, og tanken skal ikke
overleve en rejse hen for at finde et sted at ligge. Og under feltet står nu, hvad
parseren kan læse: #projekt, /sektion, @etiket, p1, en dato. Det stod i
pladsholderen før, som er det ene sted en hjælp ikke kan stå: den forsvinder ved
første anslag, altså præcis når den begynder at være nyttig.

Prisen er de enkelte bogstaver som genveje. Ét tastatur kan ikke både stave "tal
med Anders" og springe til I dag på samme t, og det, man gør halvtreds gange om
dagen, er at fange. Navigation ligger i paletten, som kan mere og koster ét
anslag. Genvejssiden er rettet, så den ikke lover q, t og u.

Undervejs, i den rækkefølge testene fandt dem: reglen var først "er det et
tekstfelt?", hvilket stjal bogstaver fra en fokuseret knap eller liste — den er nu
"har noget som helst fokus?". Hjælpelinjen lå i flowet og skubbede hele listen ned,
når feltet fik fokus, så klik landede en række forkert; tolv tests fangede det.
Den svæver nu, og efter at have svævet ovenpå den første opgave og slugt dens klik
tager den ikke længere imod pegeredskaber.

Og en fejl, der allerede lå der: q slog feltet op på aria-label="Ny opgave", så
genvejen gjorde stille ingenting for enhver, der kørte fladen på engelsk. Feltet
findes nu på et mærke, ikke på en oversat tekst.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 21:45:31 +02:00
Kristian WindandClaude Opus 5 a38a79e9b6 feat(secrets): tokens og kodeord forlader ikke længere huset med en kopi
Gmails refresh-token lå i klartekst i databasen. Det er ikke filen på serveren,
der er problemet, men kopierne af den: sikkerhedskopier kan hentes ned gennem
fladen, så hver eneste kopi, der er taget indtil nu, bar et levende token.

Derfor ligger nøglen uden for databasen — i miljøet, eller i en fil ved siden af
dataene med tilstand 0600. VAPID-parret er præcedens for en hemmelighed,
instansen selv laver og gemmer i basen, men det mønster dur ikke her: en nøgle i
samme fil som det, den låser, er ingen lås. Backup er VACUUM INTO af selve
databasen, så filen følger ikke med.

Kun de navngivne felter forsegles — refresh_token, access_token, password,
client_secret. Resten af klatten er det, man læser, når en postkasse driller:
hvilken mappe, hvilken etiket, hvilken adresse. At kryptere det med ville ikke
købe noget og ville koste muligheden for at kigge.

Ingen migration, og ingen der skal forbinde forfra: en værdi uden mærket læses
som sig selv, og næste skrivning lægger den forseglet på plads. Kolonnen
konverterer sig selv efterhånden som den bruges, i stedet for i én omgang, der
skal have ret om hver eneste række på én gang. Der er en prøve for netop det.

Fladen siger det på siden, hvor man henter kopien — for den dag nøglen betyder
noget, er den dag, man gendanner et andet sted og undrer sig over, at postkassen
er tavs. Runen er på v4; en ny variabel uden et versionsspring er usynlig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 21:18:48 +02:00
Kristian WindandClaude Opus 5 aabbbac583 feat(imap): en postkasse, der ikke er Gmail
Første del af IMAP-understøttelsen: selve klienten, som sit eget hjørne uden
tråde ud i noget bestående. Den logger ind over TLS, henter de flagede beskeder
nyere end et givet uid, og giver dem tilbage i samme form som Gmail-klienten —
afsender, emne, uddrag — så den, der senere kalder dem, kan få at vide hvor der
skal kigges og ikke hvordan.

Flagede og ikke ulæste: ulæst er en tilstand, ejeren stadig bruger, og en synk,
der brugte den op, ville tage noget fra ham. Det er samme begrundelse som stjerner
på Gmail-siden.

TLS er ikke valgfrit, og der er ingen kontakt til at slå det fra. Det her bærer et
kodeord, der åbner en hel postkasse, og en kontakt til at fjerne beskyttelsen er
en kontakt, der en dag bliver fundet allerede slået fra.

iCloud og enhver almindelig udbyder taler det her. Office 365 gør ikke længere —
Microsoft har lukket for kodeord på IMAP og vil have sit eget OAuth — så det er
ikke en vært, man kan nå herfra, og at lade som om ville kun fejle senere og
mindre tydeligt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 20:21:45 +02:00
Kristian WindandClaude Opus 5 c151d6c98c feat(groups): omdøb med dobbeltklik, og slet der hvor man kan se hvad man sletter
Release / Build and publish image (push) Failing after 9m59s
De to ord "Omdøb" og "Slet" lå oven på gruppens navn, hver gang musen var i
nærheden. De er væk fra sidebjælken. Omdøbning starter nu ved dobbeltklik på
rækken — eller F2, hvis man har den i fokus og ikke vil bruge musen. Sletning er
flyttet til gruppens egen side, hvor projekterne, den ikke tager med sig, står på
skærmen ovenover knappen.

Dobbeltklik på et navn, der også er et link, er ikke gratis. Første forsøg lod
det første klik navigere, og så åbnede siden mens omdøbningsformularen kom frem —
farveprikkerne flyttede sig ud under markøren, hvilket testen fangede som "element
is not stable". Navigationen venter derfor to tiendedele på et muligt andet klik.
Det er under grænsen for et dobbeltklik og over grænsen for at mærke det.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 20:08:24 +02:00
Kristian WindandClaude Opus 5 bd5e86dd03 fix(errors): sig fejlen i en kode, mellemleddet ikke kaster væk
Serveren svarede 502 med en forklaring, og Cloudflare erstattede kroppen med sin
egen side. Bevist i produktion: handleren skrev 122 bytes JSON på 38 ms med
teksten "the panel refused: HTTP 401", og browseren fik en HTML-fejlside. Det
gjaldt også Gmail, som bruger samme hjælper — derfor har fladen hele tiden kunnet
sige "noget gik galt" og ikke andet, selv om serveren vidste præcis hvad der var
sket.

En proxy har lov til at gøre det ved en origin-5xx. Så det er ikke Cloudflare,
der skal laves om: 502 var den pæne kode, ikke den brugbare. Upstream-afvisninger
er nu 422, som går uændret igennem. Navnet StatusUpstreamRefused står ét sted med
grunden, så det ikke bliver rullet tilbage af en, der med rette synes 502 lyder
mere rigtigt.

Fladen læste altid fejlkoden i kroppen og ikke statuskoden, så der var intet at
rette dér.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 19:17:49 +02:00
Kristian WindandClaude Opus 5 bb25b94f96 fix(sidebar): en gruppe står nu hvor projekterne står, og hedder hele sit navn
To fejl i samme række. "Omdøb" og "Slet" lå i flowet og optog deres bredde også
usynlige, så ARBEJDE blev tegnet som "ARBE"; de ligger nu over rækkens højre
ende og dukker op ved hover. Og chevronen sad før navnet og skubbede en gruppe
toogtyve pixels længere ind end de projekter, den indeholder — så en gruppe så
ud til at ligge under dem. Den deler nu plads med gruppens prik: prikken hviler,
chevronen tager over ved hover, og bliver stående mens gruppen er foldet.

De sidste otte pixels sad i a-reglen, som giver hvert link i sidebjælken sin
egen venstrepolstring oven i rækkens.

Prøven, der holdt øje med rækken, målte at alt lå side om side — hvilket var
netop den geometri, der skulle laves om. Den måler nu det, der blev bedt om:
at navnet kan læses helt, og at gruppens prik står præcis hvor et projekts gør.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 18:10:34 +02:00
Kristian WindandClaude Opus 5 dab1827c14 fix(i18n): en engelsk flade sagde "2 opgave(r) hentet"
Release / Build and publish image (push) Failing after 10m6s
Beskeden efter "Fetch now" stod på dansk uanset sprog, og med et "(r)" i stedet
for rigtig bøjning. Den bruger nu t() og plural() som resten.

Vagten burde have fanget den og gjorde det ikke: den krævede et anførselstegn
lige efter app.toast(, og denne stod som en skabelonstreng inde i en ternær hen
over to linjer. Den læser nu hele argumentlisten frem til den matchende
slutparentes, så det er formen på kaldet og ikke tilfældig placering, der afgør,
om en streng bliver set. Sat på prøve ved at lægge den gamle tekst tilbage:
begge strenge blev fanget.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 15:14:37 +02:00
Kristian WindandClaude Opus 5 fe7ce5c853 test(panel): vis hvad genstart-kaldet gør, når panelet tier eller siger nej
Et 502 i produktion så ud som om kaldet til panelet hang. Det gør det ikke: et
panel, der aldrig svarer, giver 202 efter ti sekunder, og et panel, der afviser,
giver 502 med sin egen statuskode i teksten. Begge dele står nu som prøver, så
næste gang mistanken falder på handleren, kan den frikendes uden at gætte.

Hvad der så gav Cloudflares 502 i produktion, er stadig uafklaret.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 15:05:10 +02:00
Kristian WindandClaude Opus 5 a00cf39558 fix(gmail): "hent nu" svarer selv, i stedet for at lade Cloudflare gøre det
Release / Build and publish image (push) Failing after 9m59s
En postkasse, der ikke svarer, holdt hele forespørgslen åben. Efter hundrede
sekunder gav Cloudflare op og sendte sin egen 502-side tilbage — HTML uden
fejlkode, så fladen kunne kun sige "Noget gik galt", og fejlloggen var tom,
fordi handleren aldrig nåede at vende tilbage.

Synk får nu sit eget budget på femogtyve sekunder. Løkken tjekker fristen mellem
hver besked, og hver opgave gemmes efterhånden som den laves, så en overskredet
frist er en kortere kørsel og ikke en tabt. Baggrundsjobbet beholder den fulde
tid; det har ingen, der venter.

Endepunkterne kan nu pege et andet sted hen, så testen kan stille en postkasse
op, der aldrig svarer, og se hvor lang tid der går. Budgettet kan sættes, så
den prøve koster et sekund frem for femogtyve.

Et budget på nul betyder standarden — ikke ingen tid. Uden det ville en
opsætning, der ikke satte feltet, lade fristen udløbe før første kald og gøre
hver eneste synk til et stilfærdigt nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:43:48 +02:00
Kristian WindandClaude Opus 5 c4e480a0c4 fix(gmail): stop hiding why it failed, twice over
Release / Build and publish image (push) Failing after 10m6s
Reported from use: "Fetch now" said "something went wrong" and the error log was
empty. Both halves were the same bug wearing two hats.

`handleGmailSyncNow` answered 502 with the code `internal_error`, which the
interface maps to a generic sentence — so Google's own words, which are the whole
diagnosis, were replaced by one that says nothing. And `writeError` records
nothing; only `s.internal` does. So there was no message to read and nowhere to
look it up afterwards.

`s.upstream()` is the answer: 502 because this is somebody else's service saying
no rather than this server breaking, a code of its own so the interface can quote
what was said, and a row in the error log — which is the entire reason that log
exists. A Gmail sync failing quietly for a week is otherwise invisible: the
background job writes a line the next restart erases.

The same connection also left the mailbox unnamed. The address is learned at
connect time and that call can fail; a warning went to the log and an empty string
to the database, so the page said "an unknown account" and kept saying it. The only
way back was to disconnect and reconnect, which nobody would guess. The status
endpoint asks again when it finds none, so a wrong answer lasts until the next look
rather than until somebody reconnects.

Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including
every destructive confirmation in the app — the worst possible set to leave in one
language, since it is what somebody reads in the half-second before agreeing to
lose something. The i18n guard reads markup and attributes, and a string argument
lives in the script block. It reads those now, and found four my own grep missed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:28:44 +02:00
Kristian WindandClaude Opus 5 95378a1199 docs: hand over the day, and what is still open
The list this session started with is empty, and so is the one that grew during it
— eleven items reported by somebody using the app while it was being built, taken
in the order they arrived.

Two traps worth the next session's time, both found the hard way. A Rune's manifest
`version` has to be bumped when its variables change: the panel keys its catalogue
on that number, so new fields with an unchanged version are invisible, the upload
succeeds, and nothing reports it. And the restart button's `confirm()` cannot be
driven from automation — a browser modal blocks the renderer — which is the point,
but it means an agent testing it has to ask a person to press OK.

Three things are reported and not fixed: Gmail showing "an unknown account" because
the profile call fails into a log line that a restart erases, an unidentified
"something went wrong" on the integrations page, and three Danish strings in a
script block that the i18n guard cannot see because it only reads markup.

"Det næste, jeg selv ville tage" was three items that are now done. It says what is
actually next.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:21:56 +02:00
Kristian WindandClaude Opus 5 e004696362 fix(rune): bump the manifest version, or the new variables never appear
The three panel settings were added to the manifest in v0.12.0 and `version` was
left at 2. The panel keys its catalogue on that number, so a manifest with new
fields and an unchanged version is one it has no reason to re-read — the server's
settings page simply does not offer them, and nothing reports it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:30:43 +02:00
Kristian WindandClaude Opus 5 2b45aa33e1 feat(admin): ask the panel to restart, from verdande's own settings
Release / Build and publish image (push) Failing after 10m2s
A container cannot replace its own image from the inside, so "update" is really
"ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on the
way, which is what makes a restart an update at all. This removes the second
browser tab, not the panel's authority: the panel still does the work and still
records who asked.

Three settings, needed together, and the page names whichever are missing — an
operator reading "not configured" should not have to go and find out which of
three. The token is never sent to a browser; the page is told only whether it is
set.

Answers 202 rather than waiting. The panel stops this container as part of
answering, so the reply may never arrive, and a request cut off mid-flight is the
successful case wearing a failure's clothes. The interface believes neither answer
and polls /healthz until it responds.

Administrators only, and sessions only. A leaked API token that could restart the
server is a denial of service in one request.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:18:35 +02:00
Kristian WindandClaude Opus 5 19aa02e80c feat(projects): section headings you can count at a glance, and an Inbox that speaks the language
Two from use.

Section headings were small uppercase text over a hairline — a heading you have to
look for. A project with four sections is four groups, and the eye should be able
to count them without reading a word, so the heading gets a ground of its own and
the rows sit against the page. `--surface` rather than a tint of the accent: the
colour on that page belongs to the project, and a section is not a second thing to
colour. A step in lightness separates them in all five themes without anybody
measuring anything.

And the Inbox. The server names it at account creation, before anybody has seen a
settings page, so it is the one project name nobody chose — and an account made in
Danish went on saying "Indbakke" inside an English interface, which reads as a
string somebody forgot to translate. It was. `projectName()` translates it while it
still carries the name the server gave it; rename it and that is your name for it,
in every language, like any other project. The rename field keeps the raw name, or
editing a translated label would save the translation.

The screenshot set gains the list view, which is where sections read as bands
rather than as columns — and the shape most people actually work in.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:02:56 +02:00
Kristian WindandClaude Opus 5 fe52818ab6 feat(auth): passkeys, as a way in and as a second factor
A password and a TOTP code are both shared secrets: the server keeps something
that can be stolen from it, and the person keeps something that can be talked out
of them. A passkey is neither. The private half never leaves the device, and the
signature is bound to the origin that asked for it — so a convincing copy of the
sign-in page at another address gets a signature it cannot use.

Logging in asks for no email. The device knows which account its key belongs to,
which is not only convenience: it means the sign-in page cannot be used to find out
who has an account here. A key that verified its owner is both factors at once and
completes the login; one that only proved possession still meets the TOTP step,
exactly as a password does.

The relying party id is derived from VERDANDE_BASE_URL rather than configured
separately — a second setting that has to agree with the first is one that will
one day disagree, and the failure is a login nobody can complete. An IP address is
refused up front: no browser accepts one, and without the check the server issues
a challenge happily and the browser throws SecurityError long afterwards. The list
endpoint reports whether this deployment can offer them at all, so the interface
never shows a button that cannot work.

A challenge is stored server-side, answerable exactly once, and taken rather than
read — two requests racing the same one would both succeed, which is the shape of
a replay. A sign count that has gone backwards means the credential was cloned and
is refused rather than logged.

The Go tests cover who may reach these endpoints and that challenges are spent.
The ceremony itself needs an authenticator, so the smoke test drives Chromium's
virtual one over CDP — the only thing that can tell whether the base64url
conversions are right, since one wrong byte produces a signature that will not
verify and reads as a broken key rather than a broken encoder.

That test runs at localhost in its own context while the suite stays on the
loopback address, because WebAuthn refuses an IP and resolving localhost is slow
enough here to cost the suite minutes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:56:54 +02:00
125 changed files with 10244 additions and 339 deletions
+6
View File
@@ -60,3 +60,9 @@ node_modules.nosync/
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
# it right back.
/shots
# The working handover. It names the panel, the server id and the production
# address — a map of one person's setup, which does not belong in a public repo.
# Kept on disk because it is how a session hands over to the next one.
HANDOFF.md
NOTES-PLAN.md
+3
View File
@@ -197,6 +197,9 @@ to put one that the operator would ever see.
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
## What lives in `/data`
+12
View File
@@ -17,6 +17,7 @@ import (
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/httpapi"
"github.com/kristianwind/verdande/internal/jobs"
"github.com/kristianwind/verdande/internal/secret"
"github.com/kristianwind/verdande/internal/store"
)
@@ -54,6 +55,15 @@ func run() error {
defer db.Close()
log.Info("database ready", "path", db.Path())
// Before anything reads a setting. The key lives outside the database on
// purpose: backups can be downloaded through the interface, so a token stored
// in the clear leaves with every copy anyone takes.
box, err := secret.Open(cfg.SecretKey, cfg.DataDir)
if err != nil {
return err
}
db.UseSecrets(box)
httpapi.Version = version
api := httpapi.New(cfg, db, log, webAssets(log))
@@ -64,6 +74,8 @@ func run() error {
defer stopJobs()
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
runner.SyncGmail = api.SyncGmail
runner.SyncMailbox = api.SyncMailbox
runner.Push = api.PushToUser
runner.Start(jobCtx)
srv := &http.Server{
+46
View File
@@ -55,11 +55,57 @@ One registration serves every user on the instance; each person authorises their
own mailbox from **Settings → Gmail**. The redirect URI is derived from
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
## Secrets
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SECRET_KEY` | — | Encrypts the mail tokens and mailbox passwords in the database. 32 bytes of base64. |
Mail tokens and [mailbox](mailboxes.md) passwords are encrypted at rest, and the
key does not live in the database. That is the whole point: **backups can be
downloaded through the interface**, so a copy that ends up somewhere it should
not would otherwise open your mail.
Leave the variable empty and verdande writes a key to `secret.key` in the data
directory on first start, mode `0600`. Backups are a `VACUUM INTO` of the
database alone, so the key file is not in them.
Generate one yourself if you would rather keep it out of the volume entirely:
```
openssl rand -base64 32
```
!!! warning "Lose the key and the mailboxes must be reconnected"
Nothing else is lost — tasks, projects, files and history are not encrypted —
but a token that cannot be decrypted cannot be used, and there is no way back
from a key that is gone. If you restore a database somewhere new, bring
`secret.key` with it, or set `VERDANDE_SECRET_KEY` to the same value.
A database written before there was a key is read as it stands and encrypted the
next time each value is written, so turning this on costs nobody a reconnection.
## Update checks
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
All three together let **Settings → Notifications** restart this instance,
which is also how it picks up a new version: a container cannot replace its own
image, so the panel recreates it and pulls `:latest` on the way. The request asks
the panel for a *scheduled* restart rather than an immediate one — an immediate
one is carried out while the request is still open, and stopping the container
kills the very process waiting for the answer, so the panel never gets to the
half that starts it again. Any one of the
three alone does nothing, and the page names the ones that are missing.
The token is a panel credential with control over that server. Anybody who can
read this environment can restart it — which is true of anybody with the host
anyway — and it is never sent to a browser.
Off unless you turn it on. When on, verdande asks the public GitHub releases API
at most every six hours and sends nothing about your instance — no identifier, no
+87
View File
@@ -0,0 +1,87 @@
# Mailboxes
Star a mail and it becomes a task.
This is the other direction from [mail to task](mail.md): instead of forwarding
something, Verdande reads a mailbox you connect and makes a task out of anything
you have flagged. Nothing else in the mailbox is touched, and nothing is ever
sent.
Each mailbox belongs to the person who connected it. Two people on one instance
connect their own and see only their own — there is no shared inbox and no
administrator who can read yours.
## Gmail
**Settings → Integrations → Gmail → Connect.**
Gmail needs an OAuth client, which is the instance's registration with Google
rather than anybody's account — see [Configuration](configuration.md#gmail).
Once it is set up, each person connects their own mailbox through it.
Choose what makes a task:
| Trigger | What it reads |
|---|---|
| Starred | Anything you have starred |
| Label | Anything carrying a label you name |
!!! note "The app stays Internal if your client is Internal"
An OAuth client registered as *Internal* in a Google Workspace can only be
used by accounts in that workspace. A personal `@gmail.com` address will be
refused with `org_internal`, and that is the client working as configured.
Switching to *External* means refresh tokens that expire every seven days
until Google has verified the app — for one personal account, forwarding to
your [mail-to-task address](mail.md) is the shorter road.
## iCloud, Fastmail and anything else over IMAP
**Settings → Integrations → Mailboxes → Add a mailbox.**
You need three things:
| Field | Example |
|---|---|
| IMAP server | `imap.mail.me.com:993` |
| Username | your address |
| App password | not your ordinary password — see below |
The connection is tested before it is saved. A mailbox that cannot be read is
refused while you are still looking at the form, rather than accepted and then
failing quietly every ten minutes.
Flag a message — the flag, not unread — and it becomes a task on the next sweep.
Unread is a state you are still using yourself, and a sync that consumed it would
be taking something away.
### App passwords
Every host worth naming issues a separate password for applications, which you
can withdraw on its own without changing the one you sign in with. Use that.
- **iCloud** — [appleid.apple.com](https://appleid.apple.com) → Sign-In and
Security → App-Specific Passwords
- **Fastmail** — Settings → Privacy & Security → Integrations → New app password
The password is [encrypted at rest](configuration.md#secrets), and it is never
returned by the API once saved.
### Office 365 does not work here
Microsoft has closed IMAP to passwords; it wants its own OAuth flow. There is no
app password that will get you in, and a mailbox pointed at `outlook.office365.com`
will be refused. Forward to your [mail-to-task address](mail.md) instead.
## How often
Every ten minutes, in the background. **Fetch now** does it immediately, which is
for when you have just connected something and want to see it work.
A run reads at most twenty-five messages, so a mailbox with years of flagged mail
in it fills up over several sweeps rather than in one.
## Disconnecting
**Disconnect** on the mailbox. The tasks it made stay — they are your work now,
not the mailbox's. Nothing is read after that, and the stored credential is
deleted rather than merely disabled.
+537
View File
@@ -263,6 +263,36 @@ paths:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/nav-order:
put:
tags: [auth]
summary: The order of the fixed views in the sidebar.
description: |
I dag, Kommende, Venter and the inbox, in whatever order this person wants
them. On the account rather than in the browser: which order you want them
in is a fact about you and should be the same on the phone as on the laptop.
A list of keys rather than a number per view. The set of views changes with
the program, and a key that no longer exists costs nothing — the sidebar
draws what it recognises and appends anything new at the end, which is what
makes adding a view later not a migration. An empty list means the order the
program ships with.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
order:
type: array
maxItems: 20
items: { type: string }
example: ["inbox", "today", "upcoming", "delegated"]
responses:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/me:
get:
tags: [auth]
@@ -388,6 +418,195 @@ paths:
"204": { description: Off. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/passkeys:
get:
tags: [auth]
summary: The keys registered on this account.
description: |
A passkey is a key on a device rather than a secret in a head. The private
half never leaves the device, and the signature is bound to the origin that
asked for it — so a convincing copy of the sign-in page at another address
gets a signature it cannot use.
One row per credential, because a person has a laptop and a phone and losing
one must not mean losing the way in. The name is theirs to write: a list
nobody can read is a list nobody revokes from.
Sessions only, like `/tokens` — a leaked API token must not be able to add
its own way in, which would turn a theft into a tenancy.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
passkeys:
type: array
items: { $ref: "#/components/schemas/Passkey" }
available:
type: boolean
description: |
Whether this deployment can offer passkeys at all. False when
`VERDANDE_BASE_URL` is an IP address or otherwise not a
domain an authenticator will accept — the interface asks once
rather than discovering it from a 503 it caused by offering a
button.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/auth/passkeys/register/begin:
post:
tags: [auth]
summary: Start registering a key. Returns the options and a challenge id.
description: |
Registration is done by somebody already signed in: you cannot be handed a
way into an account you have not proved you own.
The challenge is kept server-side rather than in a cookie, because the whole
point of it is that the server chose it and remembers choosing it. It is
answerable once and expires after five minutes.
responses:
"200":
description: Pass `options` to `navigator.credentials.create()`.
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
options: { type: object, additionalProperties: true }
"503":
description: |
`VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
rather than 404: this version has passkeys, this deployment cannot offer
them, and those are different things to fix.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v1/auth/passkeys/register/finish:
post:
tags: [auth]
summary: Finish registering a key.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
name:
type: string
description: What to call it in the list — "min bærbare".
credential:
type: object
additionalProperties: true
description: The response from `navigator.credentials.create()`.
responses:
"201":
description: Registered.
content:
application/json:
schema: { $ref: "#/components/schemas/Passkey" }
"400":
description: The attempt expired, was already used, or did not verify.
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/passkeys/{passkeyID}:
parameters:
- { name: passkeyID, in: path, required: true, schema: { type: string } }
patch:
tags: [auth]
summary: Rename a key.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
responses:
"204": { description: Renamed. }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [auth]
summary: Remove a key.
description: |
Scoped in the SQL rather than checked first: an id read in one step and
deleted in the next is a window, and the reason somebody is on this screen
is usually that they think a device is gone.
responses:
"204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/auth/passkey/login/begin:
post:
tags: [auth]
summary: Start signing in with a key. No session, and no email.
description: |
A discoverable login: nobody has said who they are yet, and that is the
point. The device knows which account its key belongs to, so there is no
address to type and no list of accounts for anybody to probe.
responses:
"200":
description: Pass `options` to `navigator.credentials.get()`.
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
options: { type: object, additionalProperties: true }
"429": { $ref: "#/components/responses/RateLimited" }
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/passkey/login/finish:
post:
tags: [auth]
summary: Finish signing in with a key.
description: |
A key that verified its owner — a PIN, a fingerprint, a face — is both
factors at once, and completes the login outright. One that only proved
possession still stops at the TOTP step, exactly as a password does.
A counter that has gone backwards means the credential has been cloned. It
is refused rather than logged: that counter is the only signal this design
gives.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
credential:
type: object
additionalProperties: true
description: The response from `navigator.credentials.get()`.
responses:
"200":
description: Signed in, or `totp_required` when the key was possession only.
content:
application/json:
schema:
type: object
properties:
user: { $ref: "#/components/schemas/User" }
totp_required: { type: boolean }
"401":
description: |
One message for an unknown key and for a bad signature. Telling them
apart is a way to learn which credentials exist here.
"429": { $ref: "#/components/responses/RateLimited" }
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/sessions:
get:
tags: [auth]
@@ -1148,6 +1367,67 @@ paths:
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/panel:
get:
tags: [auth]
summary: Whether this instance can restart itself. Administrators only.
description: |
A container cannot replace its own image from the inside, so "update" is
really "ask whoever owns the container to recreate it" — Yggdrasil pulls
`:latest` on the way, which is what makes a restart an update at all.
This says whether the three settings are present. The token is never sent:
the page needs to know whether the button can work, not what the credential
is. When it cannot, the missing variables are named — an operator reading
"not configured" should not have to go and find out which of three.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
configured: { type: boolean }
panel_url: { type: string }
missing:
type: array
items: { type: string }
description: The environment variables that are not set.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/panel/restart:
post:
tags: [auth]
summary: Ask the panel to recreate this container. Administrators only.
description: |
The panel does the work and records who asked; this removes the second
browser tab, not the panel's authority.
Answers 202 rather than waiting: the panel stops this container as part of
answering, so the reply may never arrive. A request cut off mid-flight is
the successful case wearing a failure's clothes — the interface polls
`/healthz` rather than believing either answer.
Sessions only, like the rest of this group. A leaked API token that could
restart the server is a denial of service in one request.
responses:
"202":
description: The panel accepted, or was already restarting this container.
content:
application/json:
schema:
type: object
properties:
restarting: { type: boolean }
note: { type: string }
"403": { $ref: "#/components/responses/Forbidden" }
"502":
description: |
The panel refused. Usually a token without control of this server.
"503":
description: The three settings are not all present.
/api/v1/errors:
get:
tags: [auth]
@@ -2227,6 +2507,196 @@ paths:
created: { type: integer }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/notes:
get:
tags: [notes]
summary: Notes — listed, narrowed to a project, or searched.
description: >
With no parameters: the notes filed under no project, which are the
person's own. With `project`: that project's, which needs read access to
it. With `q`: a full-text search across every note they can see, matching
Danish spelling either way round — "grøn" finds "gron" and the reverse.
parameters:
- name: q
in: query
schema: { type: string }
- name: project
in: query
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
notes:
type: array
items: { $ref: "#/components/schemas/Note" }
post:
tags: [notes]
summary: Write a note.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NoteInput" }
responses:
"201":
description: Written
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/notes/{noteID}:
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
get:
tags: [notes]
summary: One note.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
tags: [notes]
summary: Change a note. Every field is optional; what is sent is what changes.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NoteInput" }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [notes]
summary: Put a note in the trash. It can be brought back.
responses:
"204": { description: Deleted }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/linking/{kind}/{targetID}:
get:
tags: [notes]
summary: What has been written about this.
description: >
The backwards direction. A task's id gives every note that mentions it,
a project's name gives every note carrying that #tag.
parameters:
- name: kind
in: path
required: true
schema: { type: string, enum: [task, note, project] }
- name: targetID
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
notes:
type: array
items: { $ref: "#/components/schemas/Note" }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/mailboxes:
get:
tags: [integrations]
summary: The mailboxes this person has connected.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
mailboxes:
type: array
items: { $ref: "#/components/schemas/Mailbox" }
post:
tags: [integrations]
summary: Connect a mailbox over IMAP.
description: >
The connection is tested before it is saved, so a mailbox that cannot be
read is refused here rather than failing quietly every ten minutes.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [host, username, password]
properties:
name: { type: string }
host: { type: string, example: "imap.mail.me.com:993" }
username: { type: string }
password: { type: string, description: "An app-specific password on every host worth naming. Stored sealed." }
folder: { type: string, default: INBOX }
responses:
"201":
description: Connected
content:
application/json:
schema: { $ref: "#/components/schemas/Mailbox" }
"400": { $ref: "#/components/responses/BadRequest" }
"409": { description: That mailbox is already connected }
"422": { $ref: "#/components/responses/Upstream" }
/api/v1/mailboxes/{mailboxID}:
delete:
tags: [integrations]
summary: Disconnect a mailbox. The tasks it made stay.
parameters:
- name: mailboxID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Disconnected }
/api/v1/mailboxes/{mailboxID}/sync:
post:
tags: [integrations]
summary: Read one mailbox now.
parameters:
- name: mailboxID
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
created: { type: integer }
partial:
type: boolean
description: The run hit its time budget. What it made is kept.
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/settings:
get:
tags: [integrations]
@@ -2832,6 +3302,47 @@ components:
items: { type: string }
schemas:
Note:
type: object
description: A page of Markdown. The body is the stored form and the exported form.
properties:
id: { type: string }
project_id: { type: string }
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
body: { type: string, description: Markdown }
pinned: { type: boolean }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
links:
type: array
description: What the body points at, read out of the text on the way in.
items:
type: object
properties:
kind: { type: string, enum: [task, note, project] }
target_id: { type: string }
NoteInput:
type: object
properties:
project_id: { type: string, description: "Empty files it under no project." }
body: { type: string, description: "Markdown. Its first line becomes the title." }
pinned: { type: boolean }
Mailbox:
type: object
description: One connected mailbox. The password and any token are never returned.
properties:
id: { type: string }
kind: { type: string, enum: [gmail, imap] }
name: { type: string }
host: { type: string }
username: { type: string }
folder: { type: string }
last_uid: { type: integer }
last_sync_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
Error:
type: object
description: |
@@ -3127,6 +3638,32 @@ components:
same thing once decoded, and telling "put it nowhere" apart from "I
did not mention it" is what makes this a PATCH.
Passkey:
type: object
properties:
id: { type: string }
name:
type: string
description: What the owner called it. "min bærbare" is what makes a list reviewable.
aaguid:
type: string
description: |
Identifies the authenticator model, which is what lets a list say
something recognisable beside a key somebody named months ago.
discoverable:
type: boolean
description: |
Whether the credential lives on the device. Only these can start a login
with no email typed first.
user_verified:
type: boolean
description: |
Whether the authenticator verified the person — a PIN, a fingerprint, a
face. One that did is both factors at once; one that only proved
possession is a first factor and still meets the TOTP step.
created_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time }
ProjectGroup:
type: object
properties:
+18 -3
View File
@@ -70,7 +70,22 @@ an error message and a lost thought.
write it, and guessing month-first would silently move a date by months rather
than failing visibly.
## The shortcuts
## Just start typing
Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for
Upcoming, ++cmd+k++ to search, and ++question++ for the full list.
There is no shortcut to remember. Press a letter anywhere in the app and you are
writing a task, with the letter you pressed already in the field. The thought does
not have to survive a journey to find somewhere to put it.
It only fires when nothing else has focus — a key pressed at a field, a button or
a link belongs to that thing.
Under the field is a line saying what the parser reads: `#` project, `/` section,
`@` label, `p1` priority, and an example date. It appears while you are typing,
which is the one moment a placeholder cannot help you.
++cmd+k++ opens the palette — search, and navigation to anywhere in the app.
!!! note "The single letters used to navigate"
++t++ for Today and ++u++ for Upcoming are gone. One keyboard cannot both
spell *tal med Anders* and jump to Today on the same ++t++, and capturing is
the thing done fifty times a day. The palette does the rest.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 167 KiB

+39
View File
@@ -0,0 +1,39 @@
# Signing in
Three ways in, and you can have all of them at once.
## A password
What the first account is created with. Nothing is emailed, nothing is registered
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
app reads the QR code. Once it is on, the password alone is not enough.
Recovery codes are shown once, when you turn it on. They are the way back if the
phone is gone, and they are not shown again.
## A passkey
**Settings → Account → Passkeys → Add a passkey.**
A key on a device instead of a secret in your head. The private half never leaves
the device — a phone's secure element, a laptop's keychain, a hardware key — and
the signature is bound to the address it was made for, so a convincing copy of the
sign-in page at a different address gets nothing.
Give each one a name you will recognise later. *min bærbare* is what makes a list
of keys reviewable, and a list nobody can read is a list nobody revokes from.
Passkeys need the address in `VERDANDE_BASE_URL` to be a hostname. Browsers refuse
to make one for a bare IP address, so an instance reached at `http://192.168.1.10:8080`
will show the passkey section as unavailable rather than failing at the moment
somebody tries.
!!! note "One is enough, two is better"
Lose the only device holding your only passkey and you are back to the
password. Keep the password, or register a second key — a phone as well as a
laptop.
+14 -2
View File
@@ -3,21 +3,33 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
github.com/teambition/rrule-go v1.8.2
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
+65 -2
View File
@@ -2,12 +2,33 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,6 +39,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -25,20 +48,60 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+30
View File
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
}
return code
}
// The relying party is derived from the instance's own base URL, and the two ways
// that goes wrong are both silent until a browser refuses.
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
for _, c := range []struct {
base string
ok bool
why string
}{
{"https://verdande.example.dk", true, "a real domain"},
{"http://localhost:5173", true, "localhost, which every browser excepts"},
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
// An IP address is not a domain. The browser throws SecurityError long
// after the server has happily issued a challenge, so it is refused here
// and the endpoints answer 503 instead.
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
{"http://[::1]:8080", false, "an IPv6 literal"},
{"", false, "no address at all"},
{"not a url", false, "not an address"},
} {
_, err := NewWebAuthn(c.base, "verdande")
if c.ok && err != nil {
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
}
if !c.ok && err == nil {
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package auth
import (
"encoding/base64"
"fmt"
"net"
"net/url"
"strings"
"github.com/go-webauthn/webauthn/webauthn"
)
// Passkeys, wrapped so the rest of the app never touches the protocol types.
//
// A password and a TOTP code are both shared secrets: the server keeps something
// that can be stolen from it, and the person keeps something that can be talked
// out of them. A passkey is neither. The private half never leaves the device, and
// the signature is bound to the origin that asked for it — so a convincing copy of
// the sign-in page at another address gets a signature it cannot use.
//
// That binding is also the one thing that can be got wrong here, and it is
// invisible when it is: the RP ID has to be the registrable domain of the address
// people actually visit. Get it wrong and registration works, login works on your
// machine, and everybody behind a different hostname is quietly locked out.
// WebAuthn is the relying party — this instance, as an authenticator sees it.
type WebAuthn struct{ *webauthn.WebAuthn }
// NewWebAuthn derives the relying party from the instance's own base URL.
//
// Derived rather than configured, because a second setting that has to agree with
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
// the failure is a login that cannot be completed, reported as "the button does
// nothing".
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
}
// The RP ID is the host without the port. A port in it is rejected by every
// browser, and localhost:5173 during development is exactly where somebody
// would first put one.
rpID := parsed.Hostname()
// An IP address is not a domain, and no browser will accept one as an RP ID —
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
// the server has happily issued a challenge. Refused here instead, so the
// endpoints answer 503 and the interface never offers a button that cannot
// work. `localhost` is the exception every browser makes, and it is what
// development runs on.
if ip := net.ParseIP(rpID); ip != nil {
return nil, fmt.Errorf(
"auth: passkeys need a domain name, and %q is an IP address — "+
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
}
w, err := webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: displayName,
// The origin is the full scheme://host:port, which is what the browser
// sends and what the signature is checked against.
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
})
if err != nil {
return nil, fmt.Errorf("auth: passkeys: %w", err)
}
return &WebAuthn{w}, nil
}
// A PasskeyUser is what the library needs to know about an account.
//
// The handle is the account id rather than anything a person chose. It is written
// into the credential on the device and cannot be changed afterwards, so it must
// not be an email address: changing your email would orphan every key you own.
type PasskeyUser struct {
ID string
Email string
Name string
Credentials []webauthn.Credential
}
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
// go in a BLOB well enough, but the id is also compared against what the browser
// sends back as base64url, and one encoding on both sides is one thing to be
// wrong about.
func EncodeCredentialID(raw []byte) string {
return base64.RawURLEncoding.EncodeToString(raw)
}
func DecodeCredentialID(encoded string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(encoded)
}
+35
View File
@@ -33,11 +33,41 @@ type Config struct {
GmailClientID string
GmailClientSecret string
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
// Empty means a key file beside the data — see internal/secret.
SecretKey string
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
// for: a self-hosted app that reaches out without being told to has broken
// the deal its operator made by self-hosting.
UpdateCheck bool
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
// A container cannot replace its own image from the inside. Restarting is the
// panel's job — it recreates the container and pulls `:latest` on the way — so
// what this needs is a way to ask. All three are required together; any one
// alone cannot do anything.
//
// The token is a panel credential with control over that server. Anybody who
// can read this environment can restart it, which is true of anybody with the
// host anyway. It is never sent to a browser: the settings page is told only
// whether it is set.
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
// front of this server. Configurable so a test can prove the budget is applied
// without spending the real one on every run.
GmailSyncBudget time.Duration
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
// in production, where it means Google.
GmailTokenURL string
GmailAPIURL string
PanelURL string
PanelToken string
PanelServerID string
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
@@ -69,7 +99,12 @@ func Load() (*Config, error) {
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
+44 -7
View File
@@ -25,20 +25,50 @@ import (
)
const (
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
tokenEndpoint = "https://oauth2.googleapis.com/token"
apiBase = "https://gmail.googleapis.com/gmail/v1"
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
// Read-only, and only mail. Not profile, not contacts, not send.
Scope = "https://www.googleapis.com/auth/gmail.readonly"
)
// Endpoints is where Google is.
//
// A field rather than a constant so a test can point it at a server it controls.
// There is no other way to exercise what happens when Gmail is slow or refuses —
// and "the sync comes back before whatever sits in front of this server gives up
// on it" is exactly the kind of promise worth a test and impossible to make
// against the real thing.
//
// The zero value is Google, so nothing outside a test has to know it exists.
type Endpoints struct {
Token string
API string
}
func (e Endpoints) token() string {
if e.Token != "" {
return e.Token
}
return defaultTokenEndpoint
}
func (e Endpoints) api() string {
if e.API != "" {
return e.API
}
return defaultAPIBase
}
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
// the instance rather than to a person: one registration, any number of users.
type Config struct {
ClientID string
ClientSecret string
RedirectURL string
// Endpoints is Google unless a test says otherwise.
Endpoints Endpoints
}
func (c Config) Configured() bool {
@@ -128,7 +158,7 @@ func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error)
}
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
@@ -171,6 +201,7 @@ func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
type Client struct {
accessToken string
endpoints Endpoints
http *http.Client
}
@@ -178,6 +209,12 @@ func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(e Endpoints) *Client {
c.endpoints = e
return c
}
// Message is the part of a Gmail message a task is made from.
type Message struct {
ID string
@@ -196,7 +233,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
var parsed struct {
EmailAddress string `json:"emailAddress"`
}
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
return "", err
}
return parsed.EmailAddress, nil
@@ -220,7 +257,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
ID string `json:"id"`
} `json:"messages"`
}
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
return nil, err
}
@@ -251,7 +288,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
} `json:"headers"`
} `json:"payload"`
}
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
return Message{}, err
}
+67 -1
View File
@@ -32,6 +32,7 @@ type userJSON struct {
// about the work and not about the screen — the sidebar's width is the
// opposite case and stays in localStorage.
SidebarCollapsed []string `json:"sidebar_collapsed"`
NavOrder []string `json:"nav_order"`
CreatedAt string `json:"created_at"`
}
@@ -42,10 +43,14 @@ func toUserJSON(u *store.User) userJSON {
if collapsed == nil {
collapsed = []string{}
}
order := u.NavOrder
if order == nil {
order = []string{}
}
return userJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
@@ -83,6 +88,30 @@ func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
// handleSetNavOrder records the order of the fixed views in the sidebar.
//
// Its own route for the same reason as the folds: this writes on every drop, and
// sharing the profile's path would make a reorder send a name and a timezone it
// was never asked to change.
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
Order []string `json:"order"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.Order) > 20 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
return
}
user := userFrom(r.Context())
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
s.storeError(w, r, "set nav order", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
@@ -573,6 +602,43 @@ func inboxName(locale string) string {
// 11:49" long after the line explaining it has gone, which tells an operator that
// something broke and gives them no way to find out what. The row outlives the
// container; Indstillinger → Fejl is where it is read.
// upstream reports a failure that came from somebody else's service.
//
// Separate from `internal` because the cause is different and so is the fix: a 500
// is this server broken, and this is Google — or whoever — saying no. The status
// says so (502), and the message is passed through rather than replaced, because
// "invalid_grant" or "insufficient authentication scopes" is the whole diagnosis
// and a generic sentence in its place throws it away.
//
// It is recorded like a 500 for exactly the reason the error log exists: a Gmail
// sync that has been failing for a week is invisible otherwise. The background job
// writes a log line the next restart erases, and nobody is watching the one screen
// that would have said so.
// StatusUpstreamRefused is what this server answers when a service it depends on
// says no. Not 502, though that is what it means: a proxy is entitled to replace
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
// "something went wrong". A 4xx passes through untouched, and the truth reaching
// the person who has to act on it beats the tidier status code.
const StatusUpstreamRefused = http.StatusUnprocessableEntity
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
s.log.Warn(what, "err", err)
userID := ""
if u := userFrom(r.Context()); u != nil {
userID = u.ID
}
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, StatusUpstreamRefused, code, err.Error())
}
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
s.log.Error(what, "err", err)
+3 -1
View File
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
BaseURL: "http://127.0.0.1",
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
+12
View File
@@ -51,6 +51,18 @@ const (
// is not "that already exists" either, and it is the one refusal on the user
// page that a person needs explained rather than merely reported.
CodeLastAdmin = "last_admin"
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
// "insufficient authentication scopes" — and they are useless behind a generic
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
// read "something went wrong", and had nowhere to go. The interface wraps the
// message in one that says where it came from.
CodeGmailFailed = "gmail_failed"
// And the same for a mailbox read over IMAP. A host's refusal is the whole
// diagnosis — a wrong app password, a host that is not listening, a folder that
// is not there — and it says it in its own words.
CodeMailboxFailed = "mailbox_failed"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
+130 -37
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
@@ -10,6 +11,11 @@ import (
"github.com/kristianwind/verdande/internal/store"
)
// defaultGmailSyncBudget bounds one "fetch now" when nothing else says otherwise.
// Well under the hundred seconds a proxy is willing to wait, so this server is the
// one that answers a slow mailbox.
const defaultGmailSyncBudget = 25 * time.Second
// gmailConfig is the OAuth client this instance authorises with.
//
// The environment wins when it is set. A value in the environment is a deliberate
@@ -17,11 +23,20 @@ import (
// applied on every recreate — and a form that could quietly override it would make
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
// to redeploy a container to paste a client id.
// gmailClient is the API client, pointed wherever the config says Google is.
func (s *Server) gmailClient(accessToken string) *gmail.Client {
return gmail.NewClient(accessToken).At(gmail.Endpoints{
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
})
}
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
cfg := gmail.Config{
ClientID: s.cfg.GmailClientID,
ClientSecret: s.cfg.GmailClientSecret,
RedirectURL: s.cfg.GmailRedirectURL(),
Endpoints: endpoints,
}
if cfg.ClientID != "" && cfg.ClientSecret != "" {
return cfg
@@ -150,7 +165,10 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
return
}
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
// Best effort: a mailbox that connects but cannot name itself is still
// connected, and the status endpoint asks again when it finds no address —
// so this failing is a delay rather than a dead end.
email, err := s.gmailClient(token.AccessToken).Profile(r.Context())
if err != nil {
s.log.Warn("gmail profile", "err", err)
}
@@ -161,15 +179,27 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
delete(settings, "pkce_state")
delete(settings, "pkce_expires")
settings["refresh_token"] = token.RefreshToken
settings["access_token"] = token.AccessToken
settings["expires_at"] = token.ExpiresAt.Unix()
settings["email"] = email
if _, ok := settings["trigger"]; !ok {
settings["trigger"] = "starred"
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
s.internal(w, r, "clear gmail handshake", err)
return
}
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
// The mailbox itself, kept beside the ones read over IMAP. Connecting again
// writes the same row: Gmail is one per person, because the OAuth flow is.
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "read gmail mailbox", err)
return
}
if box == nil {
box = &store.Mailbox{UserID: user.ID, Kind: "gmail", Trigger: "starred"}
}
box.Name = email
box.Username = email
box.RefreshToken = token.RefreshToken
box.AccessToken = token.AccessToken
box.ExpiresAt = token.ExpiresAt
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
s.internal(w, r, "save gmail tokens", err)
return
}
@@ -182,9 +212,36 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
created, err := s.SyncGmail(r.Context(), user)
// A budget of its own, well under what sits in front of this server.
//
// The sync fetches up to twenty-five messages one at a time, each with its own
// thirty-second timeout — so a slow mailbox can hold the request open for
// minutes. Cloudflare gives up at a hundred seconds and answers with its own
// 502 page, which is HTML: the browser gets no JSON, no error code and no
// message, and shows "something went wrong". Nothing reaches the error log
// either, because this handler never returned.
//
// Whatever was created by the deadline is kept: every task is committed as it
// is made, so a short budget is a shorter run rather than a lost one. The
// background job keeps the full one; it has nobody waiting.
// An unset budget is not a budget of nothing: a zero here would expire the
// context before the first call and make every sync a silent no-op.
budget := s.cfg.GmailSyncBudget
if budget <= 0 {
budget = defaultGmailSyncBudget
}
ctx, cancel := context.WithTimeout(r.Context(), budget)
defer cancel()
created, err := s.SyncGmail(ctx, user)
if err != nil {
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
if errors.Is(err, context.DeadlineExceeded) {
// Not a failure: the mailbox is slow or busy, some tasks were made, and
// the rest arrive on the next run ten minutes from now.
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
return
}
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"created": created})
@@ -195,48 +252,52 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
// Returns how many were made. Also used by the background job, which is why it
// takes a context and a user rather than a request.
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
settings, err := s.db.UserSettings(ctx, user.ID, "gmail")
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
if err != nil {
return 0, err
}
str := func(key string) string {
v, _ := settings[key].(string)
return v
if box != nil {
// The same guard as the mailboxes read over IMAP, for the same reason: a
// press of "Fetch now" during the sweep would otherwise make two runs from
// one marker.
unlock := s.lockMailbox(box.ID)
defer unlock()
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
box = fresh
}
}
refresh := str("refresh_token")
if refresh == "" {
if box == nil || box.RefreshToken == "" {
return 0, nil // not connected; nothing to do and nothing to complain about
}
query := gmail.Query(str("trigger"), str("label"))
query := gmail.Query(box.Trigger, box.Label)
if query == "" {
return 0, nil // connected but no trigger chosen
}
refresh := box.RefreshToken
// The access token is refreshed only when it has actually expired.
access := str("access_token")
expires, _ := settings["expires_at"].(float64)
if access == "" || int64(expires) <= time.Now().Unix() {
access := box.AccessToken
if access == "" || box.ExpiresAt.Before(time.Now()) {
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
if err != nil {
return 0, err
}
access = token.AccessToken
settings["access_token"] = access
settings["expires_at"] = token.ExpiresAt.Unix()
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
box.AccessToken = access
box.ExpiresAt = token.ExpiresAt
if err := s.db.SaveMailbox(ctx, box); err != nil {
return 0, err
}
}
client := gmail.NewClient(access)
client := s.gmailClient(access)
ids, err := client.List(ctx, query, 25)
if err != nil {
if err == gmail.ErrUnauthorized {
// The person revoked access, or changed their password. Forget the
// tokens rather than retrying every ten minutes forever.
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{})
_ = s.db.DeleteMailbox(ctx, user.ID, box.ID)
}
return 0, err
}
@@ -245,19 +306,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// rather than as a table: it is bounded by the query's own 30-day window, and
// a table would be one more thing to migrate for a feature this small.
seen := map[string]bool{}
if raw, ok := settings["seen"].([]any); ok {
for _, v := range raw {
if id, ok := v.(string); ok {
seen[id] = true
}
}
for _, id := range box.Seen {
seen[id] = true
}
inbox, err := s.db.InboxID(ctx, user.ID)
if err != nil {
return 0, err
}
projectID := str("project_id")
projectID := box.ProjectID
if projectID == "" {
projectID = inbox
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
@@ -269,6 +326,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
if seen[id] {
continue
}
// A spent budget ends the run rather than turning into twenty-five failed
// fetches logged one at a time.
if ctx.Err() != nil {
return created, ctx.Err()
}
msg, err := client.Get(ctx, id)
if err != nil {
s.log.Warn("gmail get message", "err", err, "id", id)
@@ -302,15 +364,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
if created > 0 {
// Only the ids Gmail still returns are kept, so the list cannot grow
// without bound as messages fall out of the 30-day window.
keep := make([]any, 0, len(ids))
keep := make([]string, 0, len(ids))
for _, id := range ids {
if seen[id] {
keep = append(keep, id)
}
}
settings["seen"] = keep
settings["last_sync"] = time.Now().Unix()
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
box.Seen = keep
box.LastSyncAt = time.Now()
if err := s.db.SaveMailbox(ctx, box); err != nil {
return created, err
}
}
@@ -425,3 +487,34 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
// gmailProfile fetches the mailbox address and stores it, refreshing the access
// token first if it has expired.
//
// Shared by the connect callback and the status endpoint. The callback is the
// natural place to learn the address, and it is also the one moment that cannot be
// retried — so the status endpoint asks again when it finds nothing, and the answer
// corrects itself on the next look.
func (s *Server) gmailProfile(ctx context.Context, box *store.Mailbox) (string, error) {
access := box.AccessToken
if access == "" || box.ExpiresAt.Before(time.Now()) {
token, err := s.gmailConfig(ctx).Refresh(ctx, box.RefreshToken)
if err != nil {
return "", err
}
access = token.AccessToken
box.AccessToken = access
box.ExpiresAt = token.ExpiresAt
}
email, err := s.gmailClient(access).Profile(ctx)
if err != nil {
return "", err
}
box.Name = email
box.Username = email
if err := s.db.SaveMailbox(ctx, box); err != nil {
return "", err
}
return email, nil
}
+160
View File
@@ -0,0 +1,160 @@
package httpapi
import (
"github.com/kristianwind/verdande/internal/store"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
// A Gmail failure has to survive being reported.
//
// It used to be hidden twice over. The handler answered 502 with the code
// `internal_error`, which the interface maps to "something went wrong" — so
// Google's own words, the only thing that says what to do, were replaced by a
// sentence that says nothing. And `writeError` records nothing, so the error log
// was empty: a person clicked Fetch now, read a generic failure, and there was
// nowhere to look it up afterwards.
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
// A Gmail that refuses, the way an expired grant does.
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// Connected, with a refresh token that will be refused.
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
}); err != nil {
t.Fatal(err)
}
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != StatusUpstreamRefused {
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
resp.StatusCode)
}
if body["code"] != "gmail_failed" {
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
"sentence, which throws the diagnosis away", body["code"])
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
}
// And it is in the error log, which is the point of having one: a sync that has
// been failing for a week is invisible otherwise.
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
rows, _ := logged["errors"].([]any)
if len(rows) == 0 {
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
}
row := rows[0].(map[string]any)
if row["what"] != "sync gmail" {
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
}
if row["status"] != float64(StatusUpstreamRefused) {
t.Errorf("status = %v, want 502", row["status"])
}
}
// A slow mailbox must not hold the request open until something in front of this
// server gives up on it.
//
// This is what was actually happening in production: each Gmail call has its own
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
// showed "something went wrong". The handler had never returned.
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
// A Gmail that never answers.
blocked := make(chan struct{})
defer close(blocked)
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-blocked:
case <-r.Context().Done():
}
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
// A second rather than the real twenty-five: what is being tested is that a
// budget is applied at all, and spending the production one on every run
// would put half a minute into the suite to learn the same thing.
cfg.GmailSyncBudget = time.Second
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// A token that has not expired, so the run goes straight to the message list
// rather than stopping at a refresh.
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
// Its own client, because the shared one gives up after ten seconds — which is
// shorter than the budget being tested and would prove nothing either way.
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
started := time.Now()
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
"its own, and whatever sits in front of this server answers for it",
err, time.Since(started).Round(time.Second))
}
defer resp.Body.Close()
took := time.Since(started)
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
// bug, whatever status eventually arrives.
// Comfortably over the one-second budget and far under any proxy's patience.
if took > 10*time.Second {
t.Errorf("the sync took %s; the budget is not being applied",
took.Round(time.Second))
}
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
}
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}
+53 -19
View File
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
if err != nil {
s.log.Warn("ai split", "err", err)
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
@@ -352,7 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
if err != nil {
s.log.Warn("ai summary", "err", err)
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
@@ -370,20 +370,39 @@ type gmailSettings struct {
}
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail settings", err)
s.internal(w, r, "gmail mailbox", err)
return
}
str := func(key string) string {
v, _ := values[key].(string)
return v
if box == nil {
writeJSON(w, http.StatusOK, gmailSettings{})
return
}
// The mailbox address, fetched again if it is missing.
//
// It is stored at connect time, and the call that fetches it can fail — a slow
// token, a scope Google had not applied yet. That failure used to be a log line
// and an empty string, so the page said "an unknown account" and went on saying
// it forever: the only way back was to disconnect and reconnect, which nobody
// would guess. Asking again here costs one request on a page nobody opens
// often, and it means the wrong answer lasts until the next look.
email := box.Username
if email == "" && box.RefreshToken != "" {
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
email = fetched
} else {
s.log.Warn("gmail profile", "err", err)
}
}
writeJSON(w, http.StatusOK, gmailSettings{
Connected: str("refresh_token") != "",
Email: str("email"),
Trigger: str("trigger"),
Label: str("label"),
Connected: box.RefreshToken != "",
Email: email,
Trigger: box.Trigger,
Label: box.Label,
})
}
@@ -394,27 +413,42 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
}
user := userFrom(r.Context())
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail settings", err)
s.internal(w, r, "gmail mailbox", err)
return
}
values["trigger"] = req.Trigger
values["label"] = req.Label
if box == nil {
// Choosing what makes a task before connecting is allowed, and the choice is
// kept: the row is the mailbox with no credential in it yet, which reads as
// not connected everywhere that asks and is skipped by the sweep.
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
}
box.Trigger = req.Trigger
box.Label = req.Label
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
s.internal(w, r, "save gmail settings", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDisconnectGmail forgets the connection, tokens and all.
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
// stay: they are the person's work now, not the mailbox's.
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
s.internal(w, r, "disconnect gmail", err)
user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail mailbox", err)
return
}
if box != nil {
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
s.internal(w, r, "disconnect gmail", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
+37 -1
View File
@@ -1172,7 +1172,7 @@ func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
}
// The single most likely thing to be wrong, so it is spelled out rather than
// left for somebody to reconstruct.
if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
}
@@ -1280,3 +1280,39 @@ func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
}
}
// Rotating the mail-to-task address, which reported "something went wrong" in
// production with nothing in the error log — the combination that means the 500 did
// not come through `s.internal()`.
func TestTheMailAddressCanBeRotated(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, before := ts.do(t, "GET", "/api/v1/mail-address", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get: status %d, body %v", resp.StatusCode, before)
}
first, _ := before["address"].(string)
if first == "" {
t.Fatal("no address to begin with")
}
resp, after := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rotate: status %d, body %v", resp.StatusCode, after)
}
second, _ := after["address"].(string)
if second == "" || second == first {
t.Errorf("the address did not change: %q → %q", first, second)
}
// And twice in a row, which is what somebody does when the first one appeared
// to fail.
resp, again := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second rotate: status %d, body %v", resp.StatusCode, again)
}
if third, _ := again["address"].(string); third == second {
t.Error("the second rotation returned the same address")
}
}
+240
View File
@@ -0,0 +1,240 @@
package httpapi
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/imap"
"github.com/kristianwind/verdande/internal/store"
)
// defaultMailboxSyncBudget bounds one read of one mailbox when somebody is waiting.
// The same reasoning as Gmail's: a mailbox that has stopped answering must not
// hold a request open until whatever sits in front of this server answers for it.
const defaultMailboxSyncBudget = 25 * time.Second
func (s *Server) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
list, err := s.db.Mailboxes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list mailboxes", err)
return
}
if list == nil {
list = []store.Mailbox{}
}
writeJSON(w, http.StatusOK, map[string]any{"mailboxes": list})
}
// handleAddMailbox connects one, after proving it can actually be read.
//
// Tested before it is saved, not after: a mailbox that was accepted and then
// silently fails every ten minutes is worse than one that refused at the door,
// where the person is still holding the password they just typed.
func (s *Server) handleAddMailbox(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
Folder string `json:"folder"`
}
if err := decodeJSON(w, r, &body); err != nil {
return
}
body.Host = strings.TrimSpace(body.Host)
body.Username = strings.TrimSpace(body.Username)
if body.Host == "" || body.Username == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, CodeValidation,
"a mailbox needs a host, a username and a password")
return
}
// A host with no port is the mistake everyone makes once, and 993 is the answer
// for every host worth naming.
if !strings.Contains(body.Host, ":") {
body.Host += ":993"
}
account := imap.Account{
Host: body.Host, Username: body.Username,
Password: body.Password, Folder: body.Folder,
}
client, err := imap.Dial(account)
if err != nil {
s.upstream(w, r, CodeMailboxFailed, "connect mailbox", err)
return
}
client.Close()
user := userFrom(r.Context())
m := &store.Mailbox{
UserID: user.ID, Kind: "imap", Name: strings.TrimSpace(body.Name),
Host: body.Host, Username: body.Username,
Password: body.Password, Folder: body.Folder,
}
if m.Name == "" {
m.Name = body.Username
}
if err := s.db.SaveMailbox(r.Context(), m); err != nil {
// The unique index. Saying which mailbox is already here beats "constraint
// failed", which is true and useless.
if strings.Contains(err.Error(), "UNIQUE") {
writeError(w, http.StatusConflict, CodeValidation,
fmt.Sprintf("%s on %s is already connected", body.Username, body.Host))
return
}
s.internal(w, r, "save mailbox", err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
if err := s.db.DeleteMailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID")); err != nil {
s.internal(w, r, "delete mailbox", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSyncMailbox reads one mailbox now, within a budget of its own.
func (s *Server) handleSyncMailbox(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
m, err := s.db.Mailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID"))
if err != nil {
s.internal(w, r, "read mailbox", err)
return
}
if m == nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such mailbox")
return
}
ctx, cancel := context.WithTimeout(r.Context(), defaultMailboxSyncBudget)
defer cancel()
created, err := s.SyncMailbox(ctx, user, m)
if err != nil {
// Whatever was made by the deadline is kept: each task is committed as it
// is made, so a short budget is a shorter run rather than a lost one.
if errors.Is(err, context.DeadlineExceeded) {
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
return
}
s.upstream(w, r, CodeMailboxFailed, "sync mailbox", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"created": created})
}
// SyncMailbox turns the flagged mail newer than the last run into tasks.
//
// Exported because the background job calls it too, on the same terms and without
// a budget: nobody is waiting there.
func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mailbox) (int, error) {
// One reader of this mailbox at a time. Held for the whole run, marker and all:
// releasing before the marker is written would leave exactly the gap this is
// here to close.
unlock := s.lockMailbox(m.ID)
defer unlock()
// Re-read under the lock. The row that came in may have been fetched before the
// run that just finished moved the marker.
if fresh, err := s.db.Mailbox(ctx, m.UserID, m.ID); err == nil && fresh != nil {
m = fresh
}
client, err := imap.Dial(imap.Account{
Host: m.Host, Username: m.Username, Password: m.Password, Folder: m.Folder,
})
if err != nil {
return 0, err
}
defer client.Close()
messages, searched, err := client.Since(m.LastUID, 25)
if err != nil {
return 0, err
}
if len(messages) == 0 {
return 0, nil
}
projectID, err := s.db.InboxID(ctx, user.ID)
if err != nil {
return 0, err
}
created := 0
highest := m.LastUID
for _, msg := range messages {
if ctx.Err() != nil {
// What was made so far is already committed and counted, and the marker
// below has to be written for it: a run that created tasks and forgot how
// far it got would make them all again on the next pass.
break
}
subject := msg.Subject
if subject == "" {
subject = "(uden emne)"
}
content := subject
if msg.From != "" {
content = msg.From + ": " + subject
}
task := &store.Task{
ProjectID: projectID,
Content: content,
Description: msg.Snippet,
Priority: 4,
CreatedBy: user.ID,
}
if err := s.db.CreateTask(ctx, task, nil); err != nil {
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
continue
}
created++
if msg.UID > highest {
highest = msg.UID
}
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
}
// A run that got all the way through moves the marker to the whole of what the
// search considered, not merely to the last message it happened to read. That
// is what stops the same mail becoming a task again on the next run when the
// server did not put a uid in the fetch reply.
if ctx.Err() == nil && searched > highest {
highest = searched
}
// Written even when the loop was cut short, and outside the request's context
// so a cancelled request still records what it did.
if highest > m.LastUID {
if err := s.db.MarkMailboxRead(context.WithoutCancel(ctx), m.ID, highest, time.Now()); err != nil {
s.log.Warn("mark mailbox read", "err", err, "mailbox", m.ID)
}
}
if ctx.Err() != nil {
return created, ctx.Err()
}
return created, nil
}
// lockMailbox serialises one mailbox against itself and returns the release.
func (s *Server) lockMailbox(id string) func() {
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
+166
View File
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
}, "task_id", "body"),
}, s.mcpAddComment)
s.registerNoteTools(m)
return m
}
@@ -496,3 +497,168 @@ func clamp(value, fallback, max int) int {
}
return value
}
// --- notes ---------------------------------------------------------------------
// registerNoteTools adds the note half of the MCP surface.
//
// Separate from the task tools for one reason worth stating: this is how a note
// gets in from somewhere else. Apple Notes has no export worth the name, but a
// model running on the machine can read Notes.app and write what it finds through
// these — one note at a time, with the text as Markdown. That is a migration
// nobody has to build a parser for.
func (s *Server) registerNoteTools(m *mcp.Server) {
m.Register(mcp.Tool{
Name: "search_notes",
Description: "Find notes by text. Matches title and body, and is generous about " +
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
"With no query it returns the most recent notes. This is the tool for " +
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("Words to look for in the title and body."),
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
}),
}, s.mcpSearchNotes)
m.Register(mcp.Tool{
Name: "create_note",
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
"also exactly what an export produces, so nothing is converted and nothing " +
"is lost. Two things in the text are read out of it and become links: " +
"`#Projekt` files the note against that project, so it appears on the " +
"project's page without anybody filing it there, and `[[Another note]]` " +
"links to a note by its title. Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
"pinned": mcp.Bool("Keep it at the top of the list."),
}),
}, s.mcpCreateNote)
m.Register(mcp.Tool{
Name: "update_note",
Description: "Change a note. Only the fields given are changed. Sending `body` " +
"replaces the whole text, so read the note first if you mean to add to it — " +
"and the links are read again from the new text, so a #tag removed is a link " +
"removed.",
InputSchema: mcp.Schema(map[string]any{
"note_id": mcp.Str("Which note."),
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
"pinned": mcp.Bool("Pin it or unpin it."),
}),
}, s.mcpUpdateNote)
}
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Text string `json:"text"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(args, &params)
limit := params.Limit
if limit <= 0 || limit > 100 {
limit = 25
}
var notes []store.Note
var err error
if strings.TrimSpace(params.Text) == "" {
notes, err = s.db.LooseNotes(ctx, userID)
} else {
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
}
if err != nil {
return nil, err
}
// The same filter the HTTP surface applies, for the same reason: a search must
// not become a way to read what you cannot open.
out := make([]map[string]any, 0, len(notes))
for _, n := range notes {
if !s.mcpMayRead(ctx, &n, userID) {
continue
}
out = append(out, map[string]any{
"id": n.ID, "title": n.Title, "body": n.Body,
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
})
if len(out) >= limit {
break
}
}
return map[string]any{"notes": out, "count": len(out)}, nil
}
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
if n.ProjectID == "" {
return n.CreatedBy == userID
}
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
return err == nil
}
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
Body string `json:"body"`
ProjectID string `json:"project_id"`
Pinned bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n := &store.Note{
CreatedBy: userID,
Body: params.Body,
Pinned: params.Pinned,
}
if params.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
n.ProjectID = params.ProjectID
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
// The links are reported back rather than left silent. A note whose #tag was
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
// the row that was written.
return map[string]any{
"id": n.ID, "title": n.Title, "links": n.Links,
}, nil
}
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
var params struct {
NoteID string `json:"note_id"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n, err := s.db.Note(ctx, params.NoteID)
if err != nil {
return nil, err
}
if n == nil || !s.mcpMayRead(ctx, n, userID) {
return nil, fmt.Errorf("no such note")
}
if n.ProjectID != "" {
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
return nil, err
}
}
if params.Body != nil {
n.Body = *params.Body
}
if params.Pinned != nil {
n.Pinned = *params.Pinned
}
if err := s.db.SaveNote(ctx, n); err != nil {
return nil, err
}
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
}
+210
View File
@@ -0,0 +1,210 @@
package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// Notes.
//
// Access follows the project, because that is where a note lives: a note in a
// shared project is readable by everybody who can read the project and writable
// by an editor. A note with no project is its author's alone — the same answer a
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
// mayReadNote reports whether this person may see the note, and mayWriteNote
// whether they may change it. Split, because a viewer on a shared project can do
// the first and not the second.
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
user := userFrom(r.Context())
if n.ProjectID == "" {
return n.CreatedBy == user.ID
}
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
return err == nil
}
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
// Searching and listing are the same endpoint, because they answer the same
// question with different words. ?q= searches; ?project= narrows; neither gives
// the loose ones.
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
found, err := s.db.SearchNotes(r.Context(), q, 50)
if err != nil {
s.internal(w, r, "search notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
return
}
if projectID := r.URL.Query().Get("project"); projectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
s.storeError(w, r, "notes in project", err)
return
}
found, err := s.db.NotesInProject(r.Context(), projectID)
if err != nil {
s.internal(w, r, "notes in project", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
return
}
// Everything they can see, not only the loose ones. Filing a note in a project
// is how it gets shared, and a list that dropped it at that moment made sharing
// look like deleting.
found, err := s.db.AllNotes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
}
// readable drops what this person may not see.
//
// Filtered after the search rather than inside it: FTS5 answers about text, and
// teaching the index about roles would put an access rule in a place nobody would
// think to look when it was wrong.
func (s *Server) readable(r *http.Request, notes []store.Note) []store.Note {
out := make([]store.Note, 0, len(notes))
for _, n := range notes {
if s.mayTouchNote(r, &n, store.RoleViewer) {
out = append(out, n)
}
}
return out
}
func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
// The same answer for "no such note" and "not yours", so an id cannot be probed.
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
writeJSON(w, http.StatusOK, n)
}
// No title. It is the note's first line and is derived on every save — a field
// here would be a promise this server cannot keep.
type noteBody struct {
ProjectID *string `json:"project_id"`
Body *string `json:"body"`
Pinned *bool `json:"pinned"`
}
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
return
}
user := userFrom(r.Context())
n := &store.Note{CreatedBy: user.ID}
if body.ProjectID != nil && *body.ProjectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, user.ID, store.RoleEditor); err != nil {
s.storeError(w, r, "create note", err)
return
}
n.ProjectID = *body.ProjectID
}
if body.Body != nil {
n.Body = *body.Body
}
if body.Pinned != nil {
n.Pinned = *body.Pinned
}
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.internal(w, r, "create note", err)
return
}
writeJSON(w, http.StatusCreated, n)
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
return
}
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "update note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if body.ProjectID != nil {
// Moving it means being allowed on both sides: you cannot file somebody
// else's note into your project, nor yours into theirs.
if *body.ProjectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
s.storeError(w, r, "move note", err)
return
}
}
n.ProjectID = *body.ProjectID
}
if body.Body != nil {
n.Body = *body.Body
}
if body.Pinned != nil {
n.Pinned = *body.Pinned
}
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.internal(w, r, "update note", err)
return
}
writeJSON(w, http.StatusOK, n)
}
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "delete note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.DeleteNote(r.Context(), n.ID); err != nil {
s.internal(w, r, "delete note", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleNotesLinking answers the backwards question for a task or a project:
// what has somebody written about this.
func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
if kind != "task" && kind != "project" && kind != "note" {
writeError(w, http.StatusBadRequest, CodeValidation, "kind must be task, project or note")
return
}
found, err := s.db.NotesLinking(r.Context(), kind, chi.URLParam(r, "targetID"))
if err != nil {
s.internal(w, r, "notes linking", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
}
+115
View File
@@ -0,0 +1,115 @@
package httpapi
import (
"net/http"
"testing"
)
// A note in a project is the project's; a note in no project is its author's.
// Both answers come from the project's own roles rather than from a rule notes
// invented for themselves, which is the point of filing them there.
func TestANoteIsNotReadableByJustAnybody(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Noget privat om #Firma",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, body)
}
id, _ := body["id"].(string)
if id == "" {
t.Fatal("no id came back")
}
// The tags were read out of the text on the way in, without being asked for.
links, _ := body["links"].([]any)
if len(links) != 1 {
t.Fatalf("the note recorded %v", body["links"])
}
// Somebody else gets the same answer as for a note that does not exist, so an
// id cannot be probed for existence.
other := ts.newUser(t, "[email protected]", "Anden")
resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode)
}
}
// Searching must not become a way to read what you cannot open. The filter is
// applied after the index, and this is what says so.
func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Hemmeligt kodeord til hjemmesiden",
}); resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, body)
}
_, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
if got := len(mine["notes"].([]any)); got != 1 {
t.Fatalf("the author found %d of their own notes", got)
}
other := ts.newUser(t, "[email protected]", "Anden")
_, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
if got := len(theirs["notes"].([]any)); got != 0 {
t.Errorf("a stranger's search returned %d notes", got)
}
}
// The backwards question, which is what makes notes worth linking at all.
func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "ring til Anders",
})
taskID, _ := task["id"].(string)
if taskID == "" {
t.Fatalf("no task id: %v", task)
}
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Aftalt at han ringer først, se /opgave/" + taskID,
}); resp.StatusCode != http.StatusCreated {
t.Fatal("the note was not written")
}
_, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil)
notes, _ := found["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("%d notes point at the task, want 1", len(notes))
}
}
// Editing the text moves the links with it. A link that outlives its mention
// claims a connection that is not there.
func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"})
id, _ := note["id"].(string)
_, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if len(before["notes"].([]any)) != 1 {
t.Fatal("the first save recorded nothing")
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{
"body": "Om #Regnskab i stedet",
}); resp.StatusCode != http.StatusOK {
t.Fatal("the note was not changed")
}
_, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if got := len(after["notes"].([]any)); got != 0 {
t.Errorf("%d notes still point at Firma", got)
}
}
@@ -98,6 +98,12 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
}
}
// PushToUser is pushToUser for callers outside this package — the reminder job,
// which has the schedule but not the keys.
func (s *Server) PushToUser(userID, title, body, projectID string) {
s.pushToUser(userID, title, body, projectID)
}
func (s *Server) pushToUser(userID, title, body, projectID string) {
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
defer cancel()
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"context"
"fmt"
"net/http"
"strings"
"time"
)
// Asking the panel to restart this instance.
//
// A container cannot replace its own image from the inside, so "update" is really
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
// the way, which is what makes a restart an update at all.
//
// The alternative was a second browser tab: read the version here, go to the
// panel, find the server, press Restart. This removes the tab, not the panel's
// authority — the panel still does the work and still records who asked.
//
// Administrators only, and sessions only. A leaked API token that could restart
// the server is a denial of service in one request, and the whole point of the
// session rule elsewhere in this file is that a stolen token must not be able to
// do things only a person at a keyboard should.
type panelStatusJSON struct {
// Configured is all three settings being present. The token itself is never
// sent: the page needs to know whether the button can work, not what the
// credential is.
Configured bool `json:"configured"`
PanelURL string `json:"panel_url,omitempty"`
// Why it cannot work, when it cannot. An operator reading "not configured"
// should not have to go and find out which of three things is missing.
Missing []string `json:"missing,omitempty"`
}
func (s *Server) panelStatus() panelStatusJSON {
var missing []string
if s.cfg.PanelURL == "" {
missing = append(missing, "VERDANDE_PANEL_URL")
}
if s.cfg.PanelToken == "" {
missing = append(missing, "VERDANDE_PANEL_TOKEN")
}
if s.cfg.PanelServerID == "" {
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
}
return panelStatusJSON{
Configured: len(missing) == 0,
PanelURL: s.cfg.PanelURL,
Missing: missing,
}
}
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.panelStatus())
}
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
status := s.panelStatus()
if !status.Configured {
writeError(w, http.StatusServiceUnavailable, CodeInternal,
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
return
}
user := userFrom(r.Context())
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
// A short timeout of its own. The panel stops this container as part of
// answering, so the reply may never arrive — that is not a failure, and a
// request that hung waiting for it would look like one.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
defer cancel()
// safe-restart rather than restart, and the difference is not politeness.
//
// The plain endpoint does its work while the request is open: it stops the
// container — which kills this process — and the panel, seeing the caller
// vanish, never gets to the starting half. That took production down and left
// it down for four minutes on 18 August, and it cannot be fixed from this side:
// any button that synchronously asks for its own death has the same shape.
//
// safe-restart is scheduled. The panel answers "Restart scheduled" and does the
// work on its own afterwards, so nothing depends on this process still being
// alive to hear the reply.
url := fmt.Sprintf("%s/api/servers/%s/safe-restart", s.cfg.PanelURL, s.cfg.PanelServerID)
body := strings.NewReader(`{"backup_first":false,"target_id":""}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
s.internal(w, r, "build panel request", err)
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
// Including "the panel killed us mid-reply", which is the successful case
// wearing a failure's clothes. The interface polls /healthz rather than
// believing either answer, so this only has to be honest about what it saw.
s.log.Warn("panel restart call did not complete", "err", err)
writeJSON(w, http.StatusAccepted, map[string]any{
"restarting": true,
"note": "the panel did not answer; it may already be restarting this container",
})
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
s.log.Error("panel refused the restart", "status", resp.StatusCode)
writeError(w, StatusUpstreamRefused, CodeInternal,
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
}
+151
View File
@@ -0,0 +1,151 @@
package httpapi
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
// A panel that never answers must not become this server's problem. The container
// is being asked to stop, so the reply may legitimately never arrive — but the
// request has to end either way, or whatever sits in front answers for it.
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Drained first. A real server reads what it was sent, and one that does not
// never learns the caller has hung up — httptest.Server.Close then waits for
// a handler that will never return, and the whole binary times out instead
// of the one request.
_, _ = io.Copy(io.Discard, r.Body)
<-r.Context().Done()
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "test-token"
cfg.PanelServerID = "server-1"
})
ts.bootstrap(t)
// Its own client: the shared one gives up at ten seconds, which is the very
// budget under test, so it could not tell a pass from a failure.
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
started := time.Now()
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatalf("the request never came back (%v after %s)", err, time.Since(started).Round(time.Second))
}
defer resp.Body.Close()
took := time.Since(started)
if took > 20*time.Second {
t.Errorf("the restart took %s; that is long enough for a proxy to answer first",
took.Round(time.Second))
}
if resp.StatusCode != http.StatusAccepted {
t.Errorf("status %d, want 202 — a panel that went quiet is the expected case, "+
"not a failure", resp.StatusCode)
}
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
// And a panel that says no must say so in this server's own words, not in a proxy's.
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusForbidden)
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "wrong-token"
cfg.PanelServerID = "server-1"
})
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != StatusUpstreamRefused {
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
}
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
t.Errorf("the reply does not say what the panel answered: %v", body)
}
}
// Forty copies of one mail arrived in a single second because two runs read the
// same marker before either wrote a new one — somebody pressing "Fetch now" while
// the ten-minute sweep was already inside the same mailbox.
//
// The lock is what stops it, and a lock is only worth having if something tries
// the door: this runs the two concurrently on purpose.
func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
// A bare server: the lock needs nothing but its own zero value, and building a
// database and a session around it would only make the test slower to read.
srv := &Server{}
var inside, most int
var mu sync.Mutex
release := make(chan struct{})
// Stands in for a run: takes the lock, notes how many are inside, and holds it
// until told to let go.
run := func(done chan<- struct{}) {
unlock := srv.lockMailbox("box-1")
mu.Lock()
inside++
if inside > most {
most = inside
}
mu.Unlock()
<-release
mu.Lock()
inside--
mu.Unlock()
unlock()
done <- struct{}{}
}
first, second := make(chan struct{}), make(chan struct{})
go run(first)
go run(second)
// Long enough that both would be inside if nothing kept them apart.
time.Sleep(100 * time.Millisecond)
close(release)
<-first
<-second
if most != 1 {
t.Errorf("%d runs were inside the same mailbox at once; that is how one mail "+
"becomes forty tasks", most)
}
// And two different mailboxes must not wait for each other: one slow host is
// not everybody's problem.
a := srv.lockMailbox("box-a")
done := make(chan struct{})
go func() {
srv.lockMailbox("box-b")()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("a second mailbox waited for the first")
}
a()
}
+404
View File
@@ -0,0 +1,404 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
// Passkeys: a key on a device instead of a secret in a head.
//
// Four endpoints, in two pairs. Registration is done by somebody already signed in
// — you cannot be handed a way into an account you have not proved you own.
// Logging in is done by somebody who is not, and is the one place here that is
// reachable without a session.
//
// Each ceremony is two requests because the browser has to talk to the
// authenticator in between: the server issues a challenge, the device signs it,
// the server checks the signature against what it remembers issuing. The challenge
// lives in the database rather than in a cookie, because the whole point of it is
// that the server chose it and remembers choosing it.
const challengeTTL = 5 * time.Minute
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
// built. 404 would read as "this version does not have passkeys"; the truth is
// that this deployment cannot offer them, which is a different thing to fix.
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
if s.passkeys != nil {
return true
}
writeError(w, http.StatusServiceUnavailable, CodeInternal,
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
return false
}
type passkeyJSON struct {
ID string `json:"id"`
Name string `json:"name"`
// AAGUID identifies the authenticator model, which is what lets the list say
// something recognisable beside a key somebody named three months ago.
AAGUID string `json:"aaguid,omitempty"`
Discoverable bool `json:"discoverable"`
UserVerified bool `json:"user_verified"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
}
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list passkeys", err)
return
}
out := make([]passkeyJSON, 0, len(keys))
for _, k := range keys {
j := passkeyJSON{
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
CreatedAt: k.CreatedAt.Format(time.RFC3339),
}
if !k.LastUsedAt.IsZero() {
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
}
out = append(out, j)
}
// Whether this deployment can offer them at all, alongside the ones that exist.
// The interface asks one question rather than discovering the answer from a 503
// it triggered by offering a button — an address an authenticator refuses is a
// deployment fact, not an error somebody caused.
writeJSON(w, http.StatusOK, map[string]any{
"passkeys": out, "available": s.passkeys != nil,
})
}
// --- registering a key ------------------------------------------------------------
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
user := userFrom(r.Context())
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
if err != nil {
s.internal(w, r, "load passkeys", err)
return
}
// Resident keys, and the authenticator asked to verify its owner. Both are
// what make this a *passkey* rather than a second factor: only a discoverable
// credential can start a login with no email typed, and only a verified one
// stands in for the password rather than beside it.
creation, session, err := s.passkeys.BeginRegistration(account,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementPreferred,
UserVerification: protocol.VerificationPreferred,
}),
// Excluding what they already have, so a second registration on the same
// device replaces nothing and reports itself instead of silently making a
// duplicate.
webauthn.WithExclusions(account.excludeList()),
)
if err != nil {
s.internal(w, r, "begin passkey registration", err)
return
}
s.issueChallenge(w, r, user.ID, "register", session, creation)
}
type finishRegistrationRequest struct {
ChallengeID string `json:"challenge_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
var req finishRegistrationRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
if !ok {
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
return
}
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
if err != nil {
s.internal(w, r, "load passkeys", err)
return
}
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "Passkey"
}
key := &store.Passkey{
UserID: user.ID,
CredentialID: auth.EncodeCredentialID(credential.ID),
PublicKey: credential.PublicKey,
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
SignCount: credential.Authenticator.SignCount,
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
UserVerified: credential.Flags.UserVerified,
Name: name,
}
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
s.internal(w, r, "store passkey", err)
return
}
writeJSON(w, http.StatusCreated, passkeyJSON{
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
CreatedAt: key.CreatedAt.Format(time.RFC3339),
})
}
// --- signing in with a key ---------------------------------------------------------
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
// Discoverable: nobody has said who they are yet, and that is the point. The
// device knows which account its key belongs to, so there is no email to type
// and no list of accounts for anybody to probe.
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
if err != nil {
s.internal(w, r, "begin passkey login", err)
return
}
s.issueChallenge(w, r, "", "login", session, assertion)
}
type finishLoginRequest struct {
ChallengeID string `json:"challenge_id"`
Credential json.RawMessage `json:"credential"`
}
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
var req finishLoginRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
if !ok {
return
}
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
return
}
// Which account, resolved from the credential the device signed with. The
// handler is given the raw id and returns the owner; the library checks the
// signature against that owner's stored public key.
var matched *store.Passkey
credential, err := s.passkeys.ValidateDiscoverableLogin(
func(rawID, userHandle []byte) (webauthn.User, error) {
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
if err != nil {
return nil, err
}
matched = key
owner, err := s.db.UserByID(r.Context(), key.UserID)
if err != nil {
return nil, err
}
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
}, *session, parsed)
if err != nil || matched == nil {
// One message, as with a wrong password: telling "no such key" apart from
// "bad signature" is a way to learn which credentials exist here.
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
return
}
// A counter that goes backwards means the credential has been cloned. Not
// every authenticator keeps one, so zero on both sides is silence rather than
// suspicion — but a real counter that has gone down is the one signal this
// design gives, and it is refused rather than logged.
if credential.Authenticator.CloneWarning {
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
return
}
owner, err := s.db.UserByID(r.Context(), matched.UserID)
if err != nil {
s.internal(w, r, "load account", err)
return
}
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
s.log.Warn("record passkey use", "err", err)
}
s.loginLimiter.reset(clientIP(r))
// A verified key is both factors at once: the device proved possession and the
// person proved they were there, which is exactly what a password plus a code
// is for. One that only proved possession still stops at the code.
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
ttl := s.cfg.SessionTTL
if pending {
ttl = 10 * time.Minute
}
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
if err != nil {
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
if pending {
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
return
}
uj := toUserJSON(owner)
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
}
// --- managing them ------------------------------------------------------------------
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeFieldErrors(w, map[string]string{"name": "required"})
return
}
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
userFrom(r.Context()).ID, name); err != nil {
s.storeError(w, r, "rename passkey", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
userFrom(r.Context()).ID); err != nil {
s.storeError(w, r, "delete passkey", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- shared plumbing -----------------------------------------------------------------
// passkeyAccount is the library's view of an account, plus the rows behind it.
type passkeyAccount struct {
*auth.PasskeyUser
stored []store.Passkey
}
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
for _, c := range a.PasskeyUser.Credentials {
out = append(out, c.Descriptor())
}
return out
}
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
keys, err := s.db.ListPasskeys(r.Context(), userID)
if err != nil {
return nil, err
}
credentials := make([]webauthn.Credential, 0, len(keys))
for _, k := range keys {
raw, err := auth.DecodeCredentialID(k.CredentialID)
if err != nil {
continue
}
credentials = append(credentials, webauthn.Credential{
ID: raw,
PublicKey: k.PublicKey,
Authenticator: webauthn.Authenticator{
SignCount: k.SignCount,
},
})
}
return &passkeyAccount{
PasskeyUser: &auth.PasskeyUser{
ID: userID, Email: email, Name: name, Credentials: credentials,
},
stored: keys,
}, nil
}
// issueChallenge stores the server's side of a ceremony and hands the browser the
// options plus the id it will answer with.
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
encoded, err := json.Marshal(session)
if err != nil {
s.internal(w, r, "encode webauthn session", err)
return
}
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
if err != nil {
s.internal(w, r, "store challenge", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
}
// takeChallenge reads a challenge once and checks it belongs where it is being
// used. A registration challenge answered by a login — or one account's answered
// by another — is refused rather than merely failing later.
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"that attempt has expired or was already used; start again")
return nil, false
}
if userID != "" && owner != userID {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
return nil, false
}
var session webauthn.SessionData
if err := json.Unmarshal(raw, &session); err != nil {
s.internal(w, r, "decode webauthn session", err)
return nil, false
}
return &session, true
}
+201
View File
@@ -0,0 +1,201 @@
package httpapi
import (
"encoding/json"
"net/http"
"testing"
"github.com/kristianwind/verdande/internal/store"
)
// What can be tested without an authenticator, which is most of the surface that
// actually goes wrong.
//
// The signature itself cannot be produced here — that needs a real device or a
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
// What is testable is everything around it: who may reach these endpoints, that a
// challenge is answerable exactly once, that one account's challenge cannot be
// answered by another, and that an expired one is refused. Those are the parts a
// mistake would make silently permissive.
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
// A leaked API token must not be able to add its own way in: that would turn a
// theft into a tenancy.
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/auth/passkeys"},
{"POST", "/api/v1/auth/passkeys/register/begin"},
{"POST", "/api/v1/auth/passkeys/register/finish"},
} {
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
}
// Beginning a registration hands back options and a challenge id, and the options
// carry the things that make this a passkey rather than a second factor.
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
}
if body["challenge_id"] == "" || body["challenge_id"] == nil {
t.Fatal("no challenge id came back")
}
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
if publicKey == nil {
t.Fatalf("no publicKey in the options: %v", options)
}
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
if selection["residentKey"] != "preferred" {
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
"with no email typed", selection["residentKey"])
}
if selection["userVerification"] != "preferred" {
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
"stand in for the password", selection["userVerification"])
}
// The user handle must be the account id rather than the email: it is written
// into the credential and cannot be changed, so an email there would orphan
// every key the moment somebody changed their address.
user, _ := publicKey["user"].(map[string]any)
if user["name"] != "[email protected]" {
t.Errorf("user.name = %v", user["name"])
}
if id, _ := user["id"].(string); id == "" {
t.Error("no user handle in the options")
}
}
// A challenge is answerable exactly once, and only by whom it was issued to.
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
challenge := begun["challenge_id"].(string)
// Somebody else's challenge, answered by this account. Refused before the
// signature is even looked at.
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
}
// And it is spent: the rightful owner cannot use it either now, because taking
// it is what deleted it. That is deliberate — a challenge that survives a
// failed attempt is a challenge somebody can keep trying.
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
}
}
// Logging in with a key needs no session and says nothing about who exists here.
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// No session at all — a fresh client, which is the whole point.
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
}
challenge := body["challenge_id"].(string)
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
// A discoverable login must not list credentials: that list would say which
// keys exist on this instance to anybody who asked.
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
if list, _ := allowed.([]any); len(list) > 0 {
t.Errorf("the login options list %d credentials; a discoverable login must not",
len(list))
}
}
// A garbage answer is refused with the same message a wrong key would get.
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
}
}
// A key belongs to its owner, and nobody else can rename or remove it.
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// Written straight to the store: registering one properly needs a device.
key := &store.Passkey{
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
}
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
t.Fatal(err)
}
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
t.Fatalf("the owner sees %d keys, want 1", len(keys))
}
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
t.Errorf("somebody else sees %d of this account's keys", len(keys))
}
for _, c := range []struct {
what, method string
body any
}{
{"rename", "PATCH", map[string]any{"name": "min nu"}},
{"delete", "DELETE", nil},
} {
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
}
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner renaming: status %d", resp.StatusCode)
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner deleting: status %d", resp.StatusCode)
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ type projectJSON struct {
SortOrder float64 `json:"sort_order"`
Role string `json:"role"`
MemberCount int `json:"member_count"`
OpenCount int `json:"open_count"`
Shared bool `json:"shared"`
// GroupID is the sidebar group the asking user has filed it under, empty for
// none. Always empty on a project shared with them: grouping is a column on
@@ -34,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
return projectJSON{
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount,
Shared: p.MemberCount > 1, GroupID: p.GroupID,
}
}
+70
View File
@@ -11,11 +11,13 @@ import (
"os"
"path"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
"github.com/kristianwind/verdande/internal/mcp"
@@ -39,6 +41,24 @@ type Server struct {
mcp *mcp.Server
updates *update.Checker
// syncing serialises reads of one mailbox against itself. Two runs at once —
// somebody pressing "Fetch now" while the ten-minute sweep is in the middle of
// the same mailbox — both start from the marker as it was, both fetch the same
// messages, and both make tasks out of them. That is how forty copies of one
// mail arrived in a single second.
//
// A map of mutexes rather than one lock: two people's mailboxes have nothing to
// say to each other, and a single lock would make one slow host everybody's
// problem. Never cleaned out, because the key is a mailbox id and there are as
// many of those as there are mailboxes.
syncing sync.Map
// Nil when the base URL is not something an authenticator will accept — a bare
// hostname with no scheme, say. The endpoints answer 503 rather than the
// process refusing to start: passkeys are one way in among three, and an
// instance should not be unreachable because one of them cannot be offered.
passkeys *auth.WebAuthn
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -63,6 +83,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
s.passkeys = w
} else {
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
}
s.mcp = s.buildMCP()
s.updates = update.New(Version, cfg.UpdateCheck)
@@ -132,6 +157,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/password/reset",
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
// Signing in with a passkey. Rate-limited like the password path: a
// signature cannot be guessed, but the endpoint can still be used to
// hammer the database.
r.Method(http.MethodPost, "/passkey/login/begin",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
r.Method(http.MethodPost, "/passkey/login/finish",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
// The second step of a login: a session that has passed the password
// but not yet the code, and can reach nothing else.
r.Group(func(r chi.Router) {
@@ -145,6 +178,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Put("/nav-order", s.handleSetNavOrder)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Post("/totp/setup", s.handleTOTPSetup)
@@ -160,6 +194,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireSession)
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
// Registering a key, and the list of them. Sessions only, for
// the same reason: a leaked token must not be able to add its
// own way in — that would turn a theft into a tenancy.
r.Get("/passkeys", s.handleListPasskeys)
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
})
})
})
@@ -231,6 +274,28 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/version", s.handleVersion)
// Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each
// belongs to the person who connected it, so there is no admin gate
// here and no shared registration: the credential is theirs.
// Notes. Listing and searching are the same endpoint because they answer
// the same question with different words; the backwards one — what has
// been written about this task — hangs off the thing being asked about.
r.Route("/notes", func(r chi.Router) {
r.Get("/", s.handleListNotes)
r.Post("/", s.handleCreateNote)
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
})
r.Route("/mailboxes", func(r chi.Router) {
r.Get("/", s.handleListMailboxes)
r.Post("/", s.handleAddMailbox)
r.Delete("/{mailboxID}", s.handleDeleteMailbox)
r.Post("/{mailboxID}/sync", s.handleSyncMailbox)
})
r.Route("/gmail", func(r chi.Router) {
r.Get("/", s.handleGetGmail)
r.Put("/", s.handleSetGmail)
@@ -318,6 +383,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
// Restarting this instance from its own interface. The panel does
// the work — a container cannot replace its own image — and pulls
// `:latest` on the way, which is what makes a restart an update.
r.Get("/panel", s.handlePanelStatus)
r.Post("/panel/restart", s.handleRestartFromPanel)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
+83
View File
@@ -20,6 +20,89 @@ import (
// away whatever letters it happens to use. A word list rather than a language
// detector: this repository ships one static binary, and a list of thirty common
// words catches the sentences a person would actually write.
// The same words, inside the code rather than the markup.
//
// `confirm()`, `alert()` and `app.toast()` take a string argument, and a string
// argument lives in the script block where the scanner above never looks. Six of
// them survived the whole i18n sweep that way — every destructive confirmation in
// the app, which is the worst possible set to leave in one language, because it is
// the text somebody reads in the half-second before they agree to lose something.
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
// Every string literal in the argument list of one of the three, not just one
// sitting flush against the paren. A toast written as a ternary over two lines
// hid Danish from the earlier version of this guard for a whole release.
// Deliberately still narrow: a general scan of every literal in a script block
// would drown in class names, keys and query fragments.
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
var found []string
root := filepath.Join("..", "..", "web", "src")
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
if !strings.HasSuffix(path, ".svelte") && !strings.HasSuffix(path, ".js") {
return nil
}
if strings.Contains(path, "locales") {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
rel, _ := filepath.Rel(root, path)
src := string(body)
for _, loc := range call.FindAllStringIndex(src, -1) {
for _, lit := range literalsInCall(src[loc[1]:]) {
if danish.MatchString(lit) {
found = append(found, rel+": "+lit)
}
}
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
if len(found) > 0 {
t.Errorf("%d dialog or toast string(s) are written in Danish inside the code. "+
"They are what somebody reads before agreeing to lose something, and an "+
"English interface shows them in Danish:\n %s",
len(found), strings.Join(found, "\n "))
}
}
// literalsInCall returns the string literals in one argument list, given the text
// just past its opening paren. It stops at the matching close paren so the next
// statement's strings are somebody else's problem.
func literalsInCall(src string) []string {
var out []string
depth := 1
for i := 0; i < len(src); i++ {
switch c := src[i]; c {
case '(':
depth++
case ')':
if depth--; depth == 0 {
return out
}
case '\'', '"', '`':
end := strings.IndexByte(src[i+1:], c)
if end < 0 {
return out
}
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
out = append(out, lit)
}
i += end + 1
}
}
return out
}
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
// Text nodes of three characters or more, and the three attributes a person
// reads. Anything inside {…} is already an expression — usually a t() call —
+312
View File
@@ -0,0 +1,312 @@
// Package imap reads a mailbox over IMAP and hands back the messages that should
// become tasks.
//
// This exists next to internal/gmail rather than inside it because the two agree
// on almost nothing. Gmail is an HTTP API with OAuth, labels and stars; IMAP is a
// connection with a password, folders and flags. What they do share is the shape
// of the answer — a handful of messages, each with a sender, a subject and enough
// of a body to read — and that shape is Message here, deliberately identical to
// what the Gmail client returns, so the caller can be told where to look and not
// how to look.
//
// iCloud, Fastmail and any ordinary host speak this. Office 365 no longer accepts
// a password here at all — it wants OAuth of its own — so it is not a host you can
// reach with this package, and pretending otherwise would only fail later and
// less clearly.
package imap
import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-message"
)
// Message is one mail, reduced to what a task needs.
type Message struct {
UID uint32
From string
Subject string
Snippet string
Date time.Time
}
// Account is one mailbox to read.
type Account struct {
Host string // "imap.mail.me.com:993"
Username string
Password string // an app-specific password on every host worth naming
Folder string // "INBOX" when empty
// RootCAs adds a certificate authority to trust, on top of the system's. It
// exists so a test can put a real IMAP server behind a self-signed certificate
// and still be refused by everything else — the field can only widen trust,
// never switch verification off, which is the difference between a seam and a
// hole. Nil in production, where the system's roots are the answer.
RootCAs *x509.CertPool
}
// Dial opens a TLS connection and logs in. The caller closes it.
//
// TLS is not optional and there is no flag to make it so: this carries a password
// that unlocks a person's whole mailbox, and a switch for turning that protection
// off is a switch that will one day be found already flipped.
func Dial(a Account) (*Client, error) {
host, _, err := net.SplitHostPort(a.Host)
if err != nil {
return nil, fmt.Errorf("host must include a port, like imap.mail.me.com:993: %w", err)
}
c, err := imapclient.DialTLS(a.Host, &imapclient.Options{
TLSConfig: &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
RootCAs: a.RootCAs,
},
})
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", a.Host, err)
}
if err := c.Login(a.Username, a.Password).Wait(); err != nil {
c.Close()
// Deliberately not repeating the server's own words here. A bad password is
// the overwhelmingly likely cause, and hosts answer it with everything from
// "AUTHENTICATIONFAILED" to a sentence about web login being required.
return nil, fmt.Errorf("%s refused the login for %s: %w", a.Host, a.Username, err)
}
folder := a.Folder
if folder == "" {
folder = "INBOX"
}
return &Client{c: c, folder: folder}, nil
}
// Client is a logged-in connection to one mailbox.
type Client struct {
c *imapclient.Client
folder string
}
// Close ends the session politely, then hangs up regardless.
func (c *Client) Close() error {
if err := c.c.Logout().Wait(); err != nil {
return c.c.Close()
}
return c.c.Close()
}
// Since returns the flagged messages in the folder newer than uid.
//
// Flagged, not unread: unread is a state the person is still using, and a sync
// that consumed it would be taking something away. Flagging a mail is a deliberate
// act with no other meaning, which is exactly what is wanted — the same reason the
// Gmail side reads stars.
// The second return is the highest uid the search considered. A caller that got
// through every message can move its marker to that, and then it does not matter
// whether the server put a uid in each fetch reply — which some do not, and which
// is how the same mail became a task again on every run.
func (c *Client) Since(uid uint32, limit int) ([]Message, uint32, error) {
if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return nil, 0, fmt.Errorf("open %s: %w", c.folder, err)
}
// From uid+1 to the end. A mailbox with nothing new answers with no uids
// rather than an error, and that is not a failure worth reporting upwards.
set := imap.UIDSetNum()
set.AddRange(imap.UID(uid+1), 0)
criteria := &imap.SearchCriteria{
UID: []imap.UIDSet{set},
Flag: []imap.Flag{imap.FlagFlagged},
}
found, err := c.c.UIDSearch(criteria, nil).Wait()
if err != nil {
return nil, 0, fmt.Errorf("search %s: %w", c.folder, err)
}
uids := found.AllUIDs()
// Anything at or below the marker is dropped here rather than trusted to the
// range in the query. A server that reads `1272:*` as `0:1272` — and one does,
// which is how the same mail became a task every ten minutes with the marker
// standing still at 1271 — hands back the whole mailbox. The marker is ours;
// enforcing it here costs one pass over a list of at most a few hundred
// numbers and does not depend on anybody else's reading of the spec.
kept := make([]imap.UID, 0, len(uids))
for _, u := range uids {
if uint32(u) > uid {
kept = append(kept, u)
}
}
uids = kept
if len(uids) == 0 {
return nil, uid, nil
}
if limit > 0 && len(uids) > limit {
// The newest ones. A mailbox flagged over years would otherwise make the
// first sync the largest one, which is the run most likely to be watched.
uids = uids[len(uids)-limit:]
}
want := imap.UIDSetNum(uids...)
fetch := &imap.FetchOptions{
Envelope: true,
UID: true,
// The whole message, headers included, so the MIME structure can be walked.
// TEXT alone gives the body as bytes — boundaries and part headers and all —
// which is what put "--=_be71cb39… Content-Transfer-Encoding: 8bit" at the
// front of every task made from a multipart mail.
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}
var highest uint32
for _, u := range uids {
if uint32(u) > highest {
highest = uint32(u)
}
}
msgs, err := c.c.Fetch(want, fetch).Collect()
if err != nil {
return nil, 0, fmt.Errorf("fetch from %s: %w", c.folder, err)
}
out := make([]Message, 0, len(msgs))
for i, m := range msgs {
if m.Envelope == nil {
continue
}
// The uid comes from the search when the fetch reply does not carry one.
// A missing uid is not cosmetic: it is the marker the next run starts from,
// and a marker stuck at zero makes every message arrive again on every run.
// That is what happened against a real server while the tests stayed green,
// because the server they run against always answers with it.
msgUID := uint32(m.UID)
if msgUID == 0 && i < len(uids) {
msgUID = uint32(uids[i])
}
out = append(out, Message{
UID: msgUID,
From: address(m.Envelope.From),
Subject: strings.TrimSpace(m.Envelope.Subject),
Snippet: snippet(m),
Date: m.Envelope.Date,
})
}
return out, highest, nil
}
// address renders the first sender as "Name <addr>", or just the address.
func address(list []imap.Address) string {
if len(list) == 0 {
return ""
}
a := list[0]
addr := a.Addr()
if a.Name == "" {
return addr
}
return a.Name + " <" + addr + ">"
}
// snippet is the first readable text in the mail.
//
// It walks the MIME structure rather than taking the body as bytes. text/plain is
// preferred and HTML is the fallback with its tags stripped — crudely, because
// this is the line under a task's title and not a rendering engine. The mail
// itself stays where it is.
const snippetLimit = 400
func snippet(m *imapclient.FetchMessageBuffer) string {
for _, body := range m.BodySection {
if text := readable(body.Bytes); text != "" {
return cut(text)
}
}
return ""
}
func readable(raw []byte) string {
entity, err := message.Read(bytes.NewReader(raw))
if err != nil {
// Not something this parser recognises. The raw bytes read badly but read,
// and a mail is not worth losing over its own headers.
return strings.TrimSpace(string(raw))
}
return strings.TrimSpace(fromEntity(entity, 0))
}
// fromEntity descends into a multipart mail. The depth guard is for the mail that
// nests parts inside parts inside parts — rare, and cheaper to bound than to trust.
func fromEntity(entity *message.Entity, depth int) string {
if depth > 4 {
return ""
}
if parts := entity.MultipartReader(); parts != nil {
var html string
for {
part, err := parts.NextPart()
if err != nil {
break
}
if text := fromEntity(part, depth+1); text != "" {
kind, _, _ := part.Header.ContentType()
if kind != "text/html" {
return text
}
if html == "" {
html = text
}
}
}
return html
}
body, _ := io.ReadAll(entity.Body)
kind, _, _ := entity.Header.ContentType()
if kind == "text/html" {
return stripTags(string(body))
}
return strings.TrimSpace(string(body))
}
// stripTags removes markup without pretending to understand it. Enough for one
// line of preview, and honest about being no more than that.
func stripTags(html string) string {
var b strings.Builder
depth := 0
for _, r := range html {
switch {
case r == '<':
depth++
case r == '>':
if depth > 0 {
depth--
}
case depth == 0:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func cut(text string) string {
runes := []rune(text)
if len(runes) > snippetLimit {
return string(runes[:snippetLimit]) + "…"
}
return text
}
+77
View File
@@ -0,0 +1,77 @@
package imap
import (
"strings"
"testing"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
// A host without a port is the mistake everyone makes once, and the error a TLS
// dial gives for it says nothing useful about which of the two fields is wrong.
func TestAHostWithoutAPortSaysSo(t *testing.T) {
_, err := Dial(Account{Host: "imap.mail.me.com", Username: "kw", Password: "x"})
if err == nil {
t.Fatal("a host with no port was accepted")
}
if !strings.Contains(err.Error(), "port") {
t.Errorf("the error does not mention the port: %v", err)
}
}
func TestASenderIsRenderedForReading(t *testing.T) {
for _, c := range []struct {
name string
in []imap.Address
want string
}{
{"nobody", nil, ""},
{"bare address", []imap.Address{{Mailbox: "kw", Host: "nolimit.dk"}}, "[email protected]"},
{"with a name", []imap.Address{{Name: "Kristian Wind", Mailbox: "kw", Host: "nolimit.dk"}},
"Kristian Wind <[email protected]>"},
// The first sender wins. A mail from two people is rare enough that naming
// one is better than a task titled with a list.
{"two senders", []imap.Address{
{Mailbox: "first", Host: "example.dk"},
{Mailbox: "second", Host: "example.dk"},
}, "[email protected]"},
} {
t.Run(c.name, func(t *testing.T) {
if got := address(c.in); got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
// The snippet is what shows under a task's title, so its only real duty is to end.
func TestASnippetIsTrimmedAndBounded(t *testing.T) {
long := strings.Repeat("a", snippetLimit*2)
m := &imapclient.FetchMessageBuffer{
BodySection: []imapclient.FetchBodySectionBuffer{
{Bytes: []byte(" ")},
{Bytes: []byte(" " + long + " ")},
},
}
got := snippet(m)
if strings.HasPrefix(got, " ") || strings.HasSuffix(got, " ") {
t.Errorf("the snippet is not trimmed: %q", got[:20])
}
// An empty part must not win over the one that has the text.
if !strings.HasPrefix(got, "aaa") {
t.Errorf("an empty body part was preferred: %q", got)
}
if len([]rune(got)) > snippetLimit+1 {
t.Errorf("the snippet runs to %d characters", len([]rune(got)))
}
if !strings.HasSuffix(got, "…") {
t.Error("a cut snippet does not say that it was cut")
}
}
func TestAnEmptyMailboxIsNotAnError(t *testing.T) {
if got := snippet(&imapclient.FetchMessageBuffer{}); got != "" {
t.Errorf("a mail with no body parts gave %q", got)
}
}
+318
View File
@@ -0,0 +1,318 @@
package imap
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"strings"
"testing"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
)
// The round trip, against a real IMAP server rather than a stub of one. The
// earlier tests cover the pure parts — what a sender renders as, where a snippet
// is cut — and would all have passed with the fetch itself broken.
func TestFlaggedMailComesBackAsMessages(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "Faktura 4711", from: "Anders Bo <[email protected]>", body: "Vedhæftet.", flagged: true},
{subject: "Nyhedsbrev", from: "[email protected]", body: "Læs mere", flagged: false},
{subject: "Kontrakt", from: "[email protected]", body: "Til underskrift", flagged: true},
})
client, err := Dial(Account{
Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool,
})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
// Two of the three. The newsletter is unflagged, and flagging is the whole
// signal: unread is a state the owner is still using.
if len(msgs) != 2 {
t.Fatalf("got %d messages, want the 2 flagged ones", len(msgs))
}
if msgs[0].Subject != "Faktura 4711" {
t.Errorf("first subject is %q", msgs[0].Subject)
}
if !strings.Contains(msgs[0].From, "[email protected]") {
t.Errorf("first sender is %q", msgs[0].From)
}
if msgs[0].UID == 0 {
t.Error("no uid came back, so a second run would fetch everything again")
}
// And reading on from a marker returns only what is newer, which is the whole
// of the deduplication.
rest, _, err := client.Since(msgs[0].UID, 25)
if err != nil {
t.Fatal(err)
}
if len(rest) != 1 || rest[0].Subject != "Kontrakt" {
t.Fatalf("reading on from uid %d gave %d messages", msgs[0].UID, len(rest))
}
}
func TestAWrongPasswordIsRefusedClearly(t *testing.T) {
addr, pool := serveMailbox(t, nil)
_, err := Dial(Account{Host: addr, Username: "kw", Password: "forkert", RootCAs: pool})
if err == nil {
t.Fatal("a wrong password was accepted")
}
// Hosts say everything from AUTHENTICATIONFAILED to a sentence about the web,
// so the reply names the host and the user rather than repeating their words.
if !strings.Contains(err.Error(), "kw") {
t.Errorf("the error does not say whose login was refused: %v", err)
}
}
// A certificate this process signed itself must not be enough on its own.
func TestAnUntrustedCertificateIsRefused(t *testing.T) {
addr, _ := serveMailbox(t, nil)
if _, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig"}); err == nil {
t.Fatal("a self-signed certificate was accepted without being trusted")
}
}
type mail struct {
subject, from, body string
flagged bool
// raw replaces the whole message when set, which is the only way to get a
// genuinely multipart one in.
raw string
}
// serveMailbox starts an in-process IMAP server over TLS and returns its address
// and the pool that trusts it.
func serveMailbox(t *testing.T, mails []mail) (string, *x509.CertPool) {
t.Helper()
cert, pool := selfSigned(t)
memServer := imapmemserver.New()
user := imapmemserver.NewUser("kw", "hemmelig")
if err := user.Create("INBOX", nil); err != nil {
t.Fatal(err)
}
memServer.AddUser(user)
for _, m := range mails {
flags := []imap.Flag{}
if m.flagged {
flags = append(flags, imap.FlagFlagged)
}
raw := m.raw
if raw == "" {
raw = "From: " + m.from + "\r\nSubject: " + m.subject +
"\r\nDate: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + m.body + "\r\n"
}
if _, err := user.Append("INBOX", strings.NewReader(raw), &imap.AppendOptions{
Flags: flags,
}); err != nil {
t.Fatal(err)
}
}
srv := imapserver.New(&imapserver.Options{
NewSession: func(_ *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return memServer.NewSession(), nil, nil
},
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
InsecureAuth: false,
})
t.Cleanup(func() { srv.Close() })
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
// "localhost", not the loopback address: the certificate names a host, and the
// client checks that name — as it should.
_, port, _ := net.SplitHostPort(ln.Addr().String())
return net.JoinHostPort("localhost", port), pool
}
func selfSigned(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(certPEM)
return cert, pool
}
// The bug this was written for: every press of "Fetch now" made the same mail
// into a task again. The marker the next run starts from is the uid, and if it
// comes back as zero the marker never moves.
func TestEveryMessageCarriesAUID(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "En", from: "[email protected]", body: "en", flagged: true},
{subject: "To", from: "[email protected]", body: "to", flagged: true},
})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
for _, m := range msgs {
if m.UID == 0 {
t.Fatalf("%q came back without a uid; the marker would never move and "+
"every run would make it into a task again", m.Subject)
}
}
if msgs[0].UID == msgs[1].UID {
t.Error("two messages share a uid")
}
}
// The other half of the same report: the snippet under a task's title read
// "--=_be71cb39… Content-Transfer-Encoding: 8bit Content-Type: text/plain".
func TestAMultipartMailReadsAsItsText(t *testing.T) {
const boundary = "=_be71cb3975bdfbc60a16327cb2f49c23"
body := "This is a multi-part message.\r\n" +
"--" + boundary + "\r\n" +
"Content-Transfer-Encoding: 8bit\r\n" +
"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n\r\n" +
"Hej igen,\r\n\r\nVi har normalt 3 slags. :)\r\n" +
"--" + boundary + "\r\n" +
"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
"<html><body><p>Hej igen</p></body></html>\r\n" +
"--" + boundary + "--\r\n"
addr, pool := serveMailbox(t, []mail{{
flagged: true,
raw: "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n" +
"From: [email protected]\r\nSubject: Re: Kaffebestilling\r\n" +
"Date: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + body,
}})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages", len(msgs))
}
got := msgs[0].Snippet
for _, junk := range []string{boundary, "Content-Transfer-Encoding", "Content-Type", "<html>", "<p>"} {
if strings.Contains(got, junk) {
t.Errorf("the snippet still carries %q: %q", junk, got)
}
}
if !strings.HasPrefix(got, "Hej igen") {
t.Errorf("the snippet does not start with the text of the mail: %q", got)
}
}
// The marker is ours, not the server's.
//
// A mailbox read with a marker above everything in it must come back empty. The
// query says so, but a server is free to read the range differently — one reads
// `1272:*` as `0:1272` and hands back the lot, which is how the same mail became
// a task every ten minutes while the marker stood still at 1271. The filter is
// on this side now, so the invariant holds whatever comes back.
func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "En", from: "[email protected]", body: "en", flagged: true},
{subject: "To", from: "[email protected]", body: "to", flagged: true},
{subject: "Tre", from: "[email protected]", body: "tre", flagged: true},
})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
all, highest, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("got %d messages on the first read, want 3", len(all))
}
// Read again from the marker the first run would have written.
rest, again, err := client.Since(highest, 25)
if err != nil {
t.Fatal(err)
}
if len(rest) != 0 {
t.Errorf("%d message(s) came back at or below the marker: %v", len(rest), rest[0].Subject)
}
// And the marker must not go backwards, or the next run reads them all again.
if again < highest {
t.Errorf("the marker moved back from %d to %d", highest, again)
}
// Every message, individually: none of them may reappear once passed.
for _, m := range all {
later, _, err := client.Since(m.UID, 25)
if err != nil {
t.Fatal(err)
}
for _, l := range later {
if l.UID <= m.UID {
t.Errorf("reading from %d gave %d back", m.UID, l.UID)
}
}
}
}
+63 -13
View File
@@ -37,6 +37,14 @@ type Runner struct {
// startable from main.
SyncGmail func(ctx context.Context, user *store.User) (int, error)
// SyncMailbox reads one IMAP mailbox. Supplied by the HTTP layer for the same
// reason as SyncGmail: it owns turning a message into a task.
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
// Push delivers a notification to somebody's devices. Supplied by the HTTP
// layer, which owns the VAPID keys and the subscription list.
Push func(userID, title, body, projectID string)
wg sync.WaitGroup
}
@@ -60,7 +68,7 @@ func (r *Runner) Start(ctx context.Context) {
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
// account for a to-do app. Ten minutes is fast enough for something somebody
// starred and slow enough not to look like abuse.
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -122,6 +130,15 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
"due": rem.RemindAt.Format(time.RFC3339),
})
// And to the devices that asked for it. This was missing: reminders went out
// in-app and by mail, so somebody with the tab closed and no mail configured
// got a notification that never arrived — which is the case a reminder is
// for. Notifications from other people's actions did push; only the timed
// ones did not, and the interface said "on for this device" either way.
if r.Push != nil {
r.Push(rem.UserID, rem.TaskContent, reminderBody(rem.RemindAt), rem.ProjectID)
}
if r.mail.Configured() && rem.UserEmail != "" {
link := r.cfg.BaseURL + "/projekt/" + rem.ProjectID
if err := r.mail.SendReminder(ctx, rem.UserEmail, rem.UserName, rem.TaskContent, link); err != nil {
@@ -135,6 +152,12 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
return nil
}
// reminderBody is the line under the task's title. The time rather than the date:
// a reminder arrives when it is due, so the day is today and saying so is noise.
func reminderBody(at time.Time) string {
return at.Format("15:04")
}
// --- backups ---------------------------------------------------------------------
const (
@@ -247,26 +270,53 @@ func rotateBackups(cfg *config.Config, log *slog.Logger) error {
// --- gmail ---------------------------------------------------------------------------
// syncGmailAccounts polls every connected mailbox.
// syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
//
// One at a time, and a failure on one account does not stop the others: a revoked
// token or an expired refresh is a problem for that person alone.
func (r *Runner) syncGmailAccounts(ctx context.Context) error {
if r.SyncGmail == nil {
// One sweep rather than two, now that both live in the same table. Nobody is
// waiting, so no budget of its own — the request handler has one because a person
// is watching a spinner. A host that has gone quiet costs this run its own dial
// timeout and nothing else: the loop moves on, per mailbox and per person, so one
// bad password does not stop everybody else's mail.
func (r *Runner) syncMailboxes(ctx context.Context) error {
if r.SyncMailbox == nil {
return nil
}
users, err := r.db.UsersWithGmail(ctx)
userIDs, err := r.db.UsersWithMailboxes(ctx)
if err != nil {
return err
}
for _, user := range users {
created, err := r.SyncGmail(ctx, &user)
if err != nil {
r.log.Warn("gmail sync", "err", err, "user", user.ID)
for _, id := range userIDs {
user, err := r.db.UserByID(ctx, id)
if err != nil || user == nil {
continue
}
if created > 0 {
r.log.Info("tasks created from gmail", "user", user.ID, "count", created)
boxes, err := r.db.Mailboxes(ctx, id)
if err != nil {
r.log.Warn("list mailboxes", "err", err, "user", id)
continue
}
for i := range boxes {
var created int
var err error
// Gmail is fetched over HTTP with a token, IMAP over a connection with a
// password. Which one is a property of the row, not of the loop.
if boxes[i].Kind == "gmail" {
if r.SyncGmail == nil {
continue
}
created, err = r.SyncGmail(ctx, user)
} else {
created, err = r.SyncMailbox(ctx, user, &boxes[i])
}
if err != nil {
r.log.Warn("mailbox sync", "err", err, "user", id,
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
continue
}
if created > 0 {
r.log.Info("tasks created from a mailbox", "user", id,
"kind", boxes[i].Kind, "count", created)
}
}
}
return nil
+56
View File
@@ -276,3 +276,59 @@ func backupFiles(t *testing.T, cfg *config.Config) []string {
}
return out
}
// A reminder that nobody receives is not a reminder.
//
// The job sent them in-app and by mail. Somebody with the tab closed and no mail
// server configured — which is the setup the docker one-liner produces — got
// nothing, while the settings page said notifications were on for the device.
// The push half was simply never wired: notifications from other people's actions
// went out, only the timed ones did not.
func TestADueReminderReachesTheDevices(t *testing.T) {
runner, db, _ := newRunner(t)
user := seedUser(t, db)
type sent struct{ userID, title, projectID string }
var got []sent
runner.Push = func(userID, title, body, projectID string) {
got = append(got, sent{userID, title, projectID})
}
inbox, err := db.InboxID(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
task := &store.Task{ProjectID: inbox, Content: "ring til Anders", CreatedBy: user.ID}
if err := db.CreateTask(t.Context(), task, nil); err != nil {
t.Fatal(err)
}
// A minute ago, so it is due rather than upcoming.
at := time.Now().Add(-time.Minute)
if _, err := db.CreateReminder(t.Context(), task.ID, user.ID, &at, nil); err != nil {
t.Fatal(err)
}
if err := runner.sendDueReminders(t.Context()); err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("%d reminders reached the devices, want 1", len(got))
}
if got[0].userID != user.ID {
t.Errorf("it went to %q", got[0].userID)
}
if got[0].title != "ring til Anders" {
t.Errorf("the title is %q, not the task", got[0].title)
}
// And once. A reminder that arrives on every sweep is worse than one that does
// not arrive at all.
got = nil
if err := runner.sendDueReminders(t.Context()); err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("the same reminder went out %d more times", len(got))
}
}
+141
View File
@@ -0,0 +1,141 @@
// Package secret seals the values that must not travel with a backup.
//
// The reason this exists is not the database on the server — it is the copies of
// it. Backups can be downloaded through the web interface, and every one taken so
// far carried a live Gmail refresh token in plain text. Anyone who ends up with a
// copy ends up with the mailbox.
//
// So the key must not live in the database. That rules out the pattern used for
// the VAPID pair, which the instance generates and stores in the same file it
// protects: a key kept beside what it locks is not a lock. It comes from the
// environment, or from a file next to the data that the backup does not include.
package secret
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// prefix marks a sealed value, so a mixed column can be read without guessing and
// so the scheme can be replaced later without having to.
const prefix = "v1:"
// KeyFile is where the key lands when the environment does not carry one. It sits
// beside the database rather than inside it, and the backup routine must never
// learn to include it.
const KeyFile = "secret.key"
// Box seals and opens values.
type Box struct {
aead cipher.AEAD
}
// Open loads the key, generating and saving one on first use.
//
// A key given in the environment wins: that is how a host keeps the secret out of
// its disks altogether. Otherwise a file, created 0600, and read back with its
// permissions checked — a key that anybody on the box can read is a key that is
// only ceremonially secret.
func Open(env, dataDir string) (*Box, error) {
if env != "" {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(env))
if err != nil || len(key) != 32 {
return nil, errors.New("VERDANDE_SECRET_KEY must be 32 bytes of base64 — " +
"generate one with: openssl rand -base64 32")
}
return newBox(key)
}
path := filepath.Join(dataDir, KeyFile)
raw, err := os.ReadFile(path)
switch {
case err == nil:
key, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
if decErr != nil || len(key) != 32 {
// Deliberately not regenerating. A new key here would not fix anything —
// it would silently make every stored token unreadable, which looks
// exactly like the mailbox having disconnected itself overnight.
return nil, fmt.Errorf("%s is not a 32-byte base64 key; move it aside only "+
"if you accept that every connected mailbox must be reconnected", path)
}
if info, statErr := os.Stat(path); statErr == nil && info.Mode().Perm()&0o077 != 0 {
return nil, fmt.Errorf("%s is readable by others (%04o); chmod 600 it", path, info.Mode().Perm())
}
return newBox(key)
case errors.Is(err, os.ErrNotExist):
key := make([]byte, 32)
if _, randErr := rand.Read(key); randErr != nil {
return nil, randErr
}
encoded := base64.StdEncoding.EncodeToString(key)
if writeErr := os.WriteFile(path, []byte(encoded+"\n"), 0o600); writeErr != nil {
return nil, fmt.Errorf("write %s: %w", path, writeErr)
}
return newBox(key)
default:
return nil, fmt.Errorf("read %s: %w", path, err)
}
}
func newBox(key []byte) (*Box, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Box{aead: aead}, nil
}
// Seal returns the sealed form of v. An empty string stays empty: there is nothing
// to hide, and a row full of ciphertext for "" only makes the column harder to read.
func (b *Box) Seal(v string) (string, error) {
if v == "" || IsSealed(v) {
return v, nil
}
nonce := make([]byte, b.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
out := b.aead.Seal(nonce, nonce, []byte(v), nil)
return prefix + base64.StdEncoding.EncodeToString(out), nil
}
// Unseal returns the plain value. Anything without the marker is returned as it
// is — the column holds values written before there was a key, and they are read
// as themselves until something writes them back sealed.
func (b *Box) Unseal(v string) (string, error) {
if !IsSealed(v) {
return v, nil
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(v, prefix))
if err != nil {
return "", fmt.Errorf("sealed value is not base64: %w", err)
}
if len(raw) < b.aead.NonceSize() {
return "", errors.New("sealed value is too short to contain a nonce")
}
nonce, body := raw[:b.aead.NonceSize()], raw[b.aead.NonceSize():]
out, err := b.aead.Open(nil, nonce, body, nil)
if err != nil {
// The overwhelmingly likely cause, and worth saying rather than "message
// authentication failed", which sends people looking for a corrupt row.
return "", errors.New("cannot read a stored secret: this is a different key " +
"than the one it was written with")
}
return string(out), nil
}
// IsSealed reports whether v has already been through Seal.
func IsSealed(v string) bool { return strings.HasPrefix(v, prefix) }
+149
View File
@@ -0,0 +1,149 @@
package secret
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAValueSurvivesTheRoundTrip(t *testing.T) {
b := mustBox(t)
for _, in := range []string{"1//0abc-refresh", "kodeord med æøå", strings.Repeat("x", 5000)} {
sealed, err := b.Seal(in)
if err != nil {
t.Fatal(err)
}
if strings.Contains(sealed, in) {
t.Fatal("the sealed form still contains the value")
}
out, err := b.Unseal(sealed)
if err != nil {
t.Fatal(err)
}
if out != in {
t.Errorf("got %q, want %q", out, in)
}
}
}
// Two seals of the same value must not look alike, or the column tells anyone
// reading it which accounts share a password.
func TestSealingTwiceGivesDifferentText(t *testing.T) {
b := mustBox(t)
first, _ := b.Seal("same")
second, _ := b.Seal("same")
if first == second {
t.Error("the same value sealed to the same text twice")
}
}
// The column holds values written before there was a key. Reading must not fail
// on them, and sealing must not wrap an already-sealed value a second time.
func TestPlainTextIsReadAsItself(t *testing.T) {
b := mustBox(t)
out, err := b.Unseal("1//0abc-written-before-there-was-a-key")
if err != nil {
t.Fatal(err)
}
if out != "1//0abc-written-before-there-was-a-key" {
t.Errorf("an unsealed value came back as %q", out)
}
sealed, _ := b.Seal("x")
again, _ := b.Seal(sealed)
if again != sealed {
t.Error("an already-sealed value was sealed a second time")
}
}
func TestNothingIsStillNothing(t *testing.T) {
b := mustBox(t)
if out, _ := b.Seal(""); out != "" {
t.Errorf("an empty value sealed to %q", out)
}
}
// The message somebody gets after restoring a database without its key file. It
// has to point at the key, because every other symptom points at the mailbox.
func TestTheWrongKeySaysItIsTheWrongKey(t *testing.T) {
sealed, err := mustBox(t).Seal("hemmelig")
if err != nil {
t.Fatal(err)
}
_, err = mustBox(t).Unseal(sealed)
if err == nil {
t.Fatal("another key opened it")
}
if !strings.Contains(err.Error(), "different key") {
t.Errorf("the error does not name the cause: %v", err)
}
}
func TestTheKeyFileIsMadeOnceAndKeptPrivate(t *testing.T) {
dir := t.TempDir()
if _, err := Open("", dir); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, KeyFile)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("the key file is %04o, not 0600", perm)
}
// Opening again must reuse it. A second key here would quietly make every
// stored token unreadable, and it would look like the mailboxes disconnecting
// themselves overnight.
first, _ := os.ReadFile(path)
if _, err := Open("", dir); err != nil {
t.Fatal(err)
}
second, _ := os.ReadFile(path)
if string(first) != string(second) {
t.Error("opening a second time replaced the key")
}
}
func TestAKeyOthersCanReadIsRefused(t *testing.T) {
dir := t.TempDir()
if _, err := Open("", dir); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, KeyFile)
if err := os.Chmod(path, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Open("", dir); err == nil {
t.Error("a world-readable key was accepted")
}
}
// The environment wins, so a host can keep the key off its disks altogether.
func TestTheEnvironmentBeatsTheFile(t *testing.T) {
dir := t.TempDir()
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("k", 32)))
if _, err := Open(key, dir); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, KeyFile)); !os.IsNotExist(err) {
t.Error("a key file was written even though the environment carried one")
}
if _, err := Open("too-short", dir); err == nil {
t.Error("a key that is not 32 bytes was accepted")
}
}
func mustBox(t *testing.T) *Box {
t.Helper()
b, err := Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
return b
}
+253
View File
@@ -0,0 +1,253 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Mailbox is one connected mailbox belonging to one person.
type Mailbox struct {
ID string `json:"id"`
UserID string `json:"-"`
Kind string `json:"kind"` // "gmail" or "imap"
Name string `json:"name"`
Host string `json:"host,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"-"` // never leaves the server
Folder string `json:"folder,omitempty"`
RefreshToken string `json:"-"`
AccessToken string `json:"-"`
ExpiresAt time.Time `json:"-"`
Label string `json:"label,omitempty"`
// ProjectID is where this mailbox's tasks land. Empty means the inbox, and so
// does an id the person can no longer write to.
ProjectID string `json:"project_id,omitempty"`
Trigger string `json:"trigger,omitempty"` // "starred" or "label"
// Seen is the message ids Gmail has already turned into tasks. IMAP does not
// need it — uids are monotonic, so LastUID says the same in one number — but
// Gmail's query is a rolling window rather than a marker.
Seen []string `json:"-"`
LastUID uint32 `json:"last_uid,omitempty"`
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
const mailboxColumns = `id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at`
// Mailboxes returns everything one person has connected, oldest first.
func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) {
rows, err := db.QueryContext(ctx,
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Mailbox
for rows.Next() {
m, err := db.scanMailbox(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// Mailbox returns one, and only to the person it belongs to: the id is enough to
// find a row, so the owner is part of the question rather than checked after.
func (db *DB) Mailbox(ctx context.Context, userID, id string) (*Mailbox, error) {
row := db.QueryRowContext(ctx,
`SELECT `+mailboxColumns+` FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
m, err := db.scanMailbox(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
// MailboxOfKind returns a person's mailbox of one kind, or nil. Gmail is one per
// person because the OAuth flow is: connecting again replaces what was there.
func (db *DB) MailboxOfKind(ctx context.Context, userID, kind string) (*Mailbox, error) {
row := db.QueryRowContext(ctx,
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? AND kind = ?`, userID, kind)
m, err := db.scanMailbox(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
// SaveMailbox writes one, sealing the two columns that must not travel with a
// backup. Called by hand rather than by the settings tables' own seal, which does
// not reach this table.
func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
if m.ID == "" {
m.ID = NewID()
}
if m.CreatedAt.IsZero() {
m.CreatedAt = time.Now()
}
if m.Folder == "" {
m.Folder = "INBOX"
}
password, err := db.sealValue(m.Password)
if err != nil {
return fmt.Errorf("seal password: %w", err)
}
refresh, err := db.sealValue(m.RefreshToken)
if err != nil {
return fmt.Errorf("seal refresh_token: %w", err)
}
access, err := db.sealValue(m.AccessToken)
if err != nil {
return fmt.Errorf("seal access_token: %w", err)
}
// Not sealed: which mails have been read is not a secret, and a column of
// ciphertext here would only make the table harder to look at.
seen, err := json.Marshal(m.Seen)
if err != nil {
return err
}
if m.Trigger == "" {
m.Trigger = "starred"
}
_, err = db.ExecContext(ctx, `
INSERT INTO mailboxes (`+mailboxColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = excluded.name,
host = excluded.host,
username = excluded.username,
password = excluded.password,
folder = excluded.folder,
refresh_token = excluded.refresh_token,
access_token = excluded.access_token,
expires_at = excluded.expires_at,
label = excluded.label,
trigger_kind = excluded.trigger_kind,
seen = excluded.seen,
project_id = excluded.project_id,
last_uid = excluded.last_uid,
last_sync_at = excluded.last_sync_at`,
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen), m.ProjectID,
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
return err
}
// MarkMailboxRead records how far a run got. Written separately from the rest so
// a sync never rewrites the credentials it is holding in memory.
func (db *DB) MarkMailboxRead(ctx context.Context, id string, lastUID uint32, at time.Time) error {
_, err := db.ExecContext(ctx,
`UPDATE mailboxes SET last_uid = ?, last_sync_at = ? WHERE id = ?`,
lastUID, at.Unix(), id)
return err
}
// DeleteMailbox disconnects one. The tasks it made stay: they are the person's
// work now, not the mailbox's.
func (db *DB) DeleteMailbox(ctx context.Context, userID, id string) error {
_, err := db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
return err
}
// UsersWithMailboxes returns everybody the sweep has to visit.
func (db *DB) UsersWithMailboxes(ctx context.Context) ([]string, error) {
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM mailboxes`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
type scanner interface {
Scan(dest ...any) error
}
func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
var m Mailbox
var expires, lastSync, created int64
var seen string
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
&m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires,
&m.Label, &m.Trigger, &seen, &m.ProjectID, &m.LastUID, &lastSync, &created)
if err != nil {
return m, err
}
// A row that somehow holds nonsense here is not worth failing a whole sync
// over: an empty list means everything looks new, which is recoverable.
_ = json.Unmarshal([]byte(seen), &m.Seen)
for _, field := range []*string{&m.Password, &m.RefreshToken, &m.AccessToken} {
plain, err := db.unsealValue(*field)
if err != nil {
return m, err
}
*field = plain
}
m.ExpiresAt = timeOrZero(expires)
m.LastSyncAt = timeOrZero(lastSync)
m.CreatedAt = time.Unix(created, 0)
return m, nil
}
// sealValue and unsealValue are the single-string versions of the map helpers in
// notifications.go. Without a key they pass the value through, which is what the
// tests that never set one expect.
func (db *DB) sealValue(v string) (string, error) {
if db.box == nil {
return v, nil
}
return db.box.Seal(v)
}
func (db *DB) unsealValue(v string) (string, error) {
if db.box == nil {
return v, nil
}
return db.box.Unseal(v)
}
func unixOrZero(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func timeOrZero(unix int64) time.Time {
if unix == 0 {
return time.Time{}
}
return time.Unix(unix, 0)
}
+264
View File
@@ -0,0 +1,264 @@
package store
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/secret"
)
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const password = "abcd-efgh-ijkl-mnop"
m := &Mailbox{
UserID: userID,
Kind: "imap",
Name: "iCloud",
Host: "imap.mail.me.com:993",
Username: "[email protected]",
Password: password,
}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
var stored string
if err := db.QueryRowContext(ctx,
`SELECT password FROM mailboxes WHERE id = ?`, m.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored, password) {
t.Fatalf("the password is in the row as typed: %s", stored)
}
back, err := db.Mailbox(ctx, userID, m.ID)
if err != nil {
t.Fatal(err)
}
if back == nil {
t.Fatal("the mailbox was not found")
}
if back.Password != password {
t.Errorf("got password %q", back.Password)
}
if back.Folder != "INBOX" {
t.Errorf("the folder defaulted to %q, want INBOX", back.Folder)
}
}
// The question that prompted the table: two people on one instance, each with
// their own mailbox, neither seeing the other's.
func TestAMailboxBelongsToOnePerson(t *testing.T) {
db, kristian := sealedStore(t)
ctx := context.Background()
andreas := &User{Email: "[email protected]", Name: "Andreas"}
if err := db.CreateUser(ctx, andreas, "Indbakke"); err != nil {
t.Fatal(err)
}
mine := &Mailbox{UserID: kristian, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
theirs := &Mailbox{UserID: andreas.ID, Kind: "imap", Host: "imap.fastmail.com:993", Username: "andreas"}
for _, m := range []*Mailbox{mine, theirs} {
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
}
list, err := db.Mailboxes(ctx, kristian)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Username != "kw" {
t.Fatalf("got %d mailboxes for Kristian, want only his own", len(list))
}
// And the id alone is not enough to reach somebody else's.
other, err := db.Mailbox(ctx, kristian, theirs.ID)
if err != nil {
t.Fatal(err)
}
if other != nil {
t.Error("one person read another's mailbox by its id")
}
}
// Several mailboxes each is the ordinary case — a work Gmail and a private
// iCloud — and is the whole reason this is a table rather than a setting.
func TestOnePersonCanHaveSeveral(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
for _, host := range []string{"imap.mail.me.com:993", "imap.fastmail.com:993"} {
if err := db.SaveMailbox(ctx, &Mailbox{
UserID: userID, Kind: "imap", Host: host, Username: "kw",
}); err != nil {
t.Fatal(err)
}
}
list, err := db.Mailboxes(ctx, userID)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("got %d mailboxes, want 2", len(list))
}
}
// The same mailbox twice would make two tasks out of every mail.
func TestTheSameMailboxTwiceIsRefused(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
first := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, first); err != nil {
t.Fatal(err)
}
again := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, again); err == nil {
t.Error("the same host and username was accepted a second time")
}
}
func TestReadingProgressIsRemembered(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
at := time.Now().Truncate(time.Second)
if err := db.MarkMailboxRead(ctx, m.ID, 4711, at); err != nil {
t.Fatal(err)
}
back, err := db.Mailbox(ctx, userID, m.ID)
if err != nil {
t.Fatal(err)
}
if back.LastUID != 4711 {
t.Errorf("last uid is %d", back.LastUID)
}
if !back.LastSyncAt.Equal(at) {
t.Errorf("last sync is %v, want %v", back.LastSyncAt, at)
}
// The credentials it was holding must not have been rewritten as a side effect.
if back.Host != "imap.mail.me.com:993" {
t.Errorf("the host changed to %q", back.Host)
}
}
// Disconnecting takes the mailbox, not the work it produced.
func TestDisconnectingKeepsTheTasks(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
if err := db.DeleteMailbox(ctx, userID, m.ID); err != nil {
t.Fatal(err)
}
list, err := db.Mailboxes(ctx, userID)
if err != nil {
t.Fatal(err)
}
if len(list) != 0 {
t.Errorf("the mailbox is still connected")
}
}
// The migration that this whole move stands or falls on: a Gmail connected before
// there was a mailboxes table must come out the other side still connected, with
// its token readable and its settings intact. Nobody should have to reconnect
// because the server tidied up.
func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
dir := t.TempDir()
box, err := secret.Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
// A database at the state before 0017, with a sealed token written the way the
// settings table wrote them.
db, userID := storeAt(t, dir, box)
sealed, err := box.Seal("1//0-the-original-refresh-token")
if err != nil {
t.Fatal(err)
}
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
if _, err := db.ExecContext(context.Background(), `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
userID, values); err != nil {
t.Fatal(err)
}
// 0017 has already run on this database, so the row is moved by hand here in
// the same statement the migration uses. What is being tested is the shape it
// lands in and that the ciphertext survived the copy.
if _, err := db.ExecContext(context.Background(), `
INSERT INTO mailboxes (id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at)
SELECT lower(hex(randomblob(16))), s.user_id, 'gmail',
json_extract(s.values_json, '$.email'), '', json_extract(s.values_json, '$.email'),
'', 'INBOX', json_extract(s.values_json, '$.refresh_token'), '', 0,
json_extract(s.values_json, '$.label'), json_extract(s.values_json, '$.trigger'),
json_extract(s.values_json, '$.seen'), json_extract(s.values_json, '$.project_id'),
0, 0, unixepoch()
FROM user_settings s WHERE s.scope = 'gmail'`); err != nil {
t.Fatal(err)
}
moved, err := db.MailboxOfKind(context.Background(), userID, "gmail")
if err != nil {
t.Fatal(err)
}
if moved == nil {
t.Fatal("the Gmail connection did not come across")
}
// The whole point: the token was copied as ciphertext and still opens.
if moved.RefreshToken != "1//0-the-original-refresh-token" {
t.Errorf("the token came out as %q", moved.RefreshToken)
}
if moved.Trigger != "label" || moved.Label != "Verdande" {
t.Errorf("the trigger came out as %q/%q", moved.Trigger, moved.Label)
}
if moved.Username != "[email protected]" {
t.Errorf("the address came out as %q", moved.Username)
}
if moved.ProjectID != "p-1" {
t.Errorf("the destination project came out as %q", moved.ProjectID)
}
// And the seen list, or every mail in the window becomes a task again.
if len(moved.Seen) != 2 || moved.Seen[0] != "abc" {
t.Errorf("the seen list came out as %v", moved.Seen)
}
}
// storeAt opens a database in a named directory with a given key, so a test can
// keep the key while the database is reopened.
func storeAt(t *testing.T, dir string, box *secret.Box) (*DB, string) {
t.Helper()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
db.UseSecrets(box)
user := &User{Email: "[email protected]", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
@@ -0,0 +1,73 @@
-- Passkeys: a key on a device instead of a secret in a head.
--
-- The account already has two ways in — a password, and a TOTP code on top of it.
-- Both are shared secrets: the server keeps something that can be stolen from it,
-- and the person keeps something that can be phished out of them. A passkey is
-- neither. The private half never leaves the device, the public half is all this
-- table holds, and the signature is bound to the origin — so a convincing copy of
-- the sign-in page at a different address gets nothing.
--
-- One row per credential rather than per account, because a person has more than
-- one device and losing a laptop should not mean losing the way in. The name is
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
-- list nobody can read is a list nobody revokes from.
--
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
-- a counter that goes backwards means the credential has been cloned, which is
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
CREATE TABLE passkeys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- The credential id the authenticator generated, raw bytes, base64url as
-- stored. Unique across the instance: it is what a login is looked up by,
-- before anybody has said who they are.
credential_id TEXT NOT NULL,
public_key BLOB NOT NULL,
-- The AAGUID identifies the authenticator model, which is what lets a list say
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
aaguid TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
-- Whether the credential lives on the device itself (a resident key). Only
-- those can start a login with no email typed first, so the interface has to
-- know which it has before it offers that.
discoverable INTEGER NOT NULL DEFAULT 0,
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
-- face. A key that only proves possession is a first factor; one that verified
-- its owner is both, and that is what decides whether a password is still
-- asked for.
user_verified INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
-- The challenge a registration or a login is answered against.
--
-- Kept server-side rather than in a cookie: the whole point of the challenge is
-- that the server chose it and remembers choosing it. One row per attempt, deleted
-- when it is answered, and swept when it expires — a challenge that outlives its
-- ceremony is a replay waiting to be tried.
--
-- user_id is null for a login that has not said who it is yet, which is the case
-- a discoverable credential exists for.
CREATE TABLE webauthn_challenges (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
challenge TEXT NOT NULL,
-- 'register' or 'login'. A challenge issued for one must not be answerable by
-- the other: registering is done by somebody already signed in, and logging in
-- is not.
purpose TEXT NOT NULL,
-- The library's own session state, which carries the allowed credentials and
-- the verification requirement it will check the answer against.
session BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
CHECK (purpose IN ('register', 'login'))
);
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
@@ -0,0 +1,57 @@
-- Mailboxes: one row per connected mailbox, not one setting per person.
--
-- Gmail lived in `user_settings` under scope 'gmail'. That table has `user_id` as
-- its whole primary key, so it holds exactly one scope per person and its upsert
-- rewrites the scope name on conflict — adding IMAP beside Gmail there would have
-- deleted the Gmail connection without saying so. Migration 0011 warns about this
-- in as many words, and the warning was nearly walked into a second time.
--
-- The shape is wrong for the thing anyway. A person has mailboxes, plural: a work
-- Gmail and a private iCloud is the ordinary case, not the exotic one. A row each
-- says that. It also answers the question that prompted this — every mailbox
-- belongs to a user, so two people on the same instance connect their own and see
-- only their own. The one piece that stays instance-wide is the Google OAuth
-- client, which is a single registration everybody signs in through, and that is
-- correct: it is the app, not the account.
--
-- `kind` rather than two tables. What differs is how the credential is obtained —
-- OAuth for Gmail, a password for IMAP — and what differs is a nullable column,
-- not a schema. What is the same is everything the sync loop touches: which
-- mailbox, how far it has read, when it last ran.
--
-- `password` and `refresh_token` are written sealed; see internal/secret. The
-- store's own seal/unseal only covers the settings tables, so this table's methods
-- call them by hand — a fact worth knowing before adding a third secret column.
--
-- `last_uid` is IMAP's place-marker and means nothing for Gmail, which keeps its
-- own by query. Zero is "nothing read yet" for both.
CREATE TABLE mailboxes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('gmail', 'imap')),
name TEXT NOT NULL DEFAULT '',
-- IMAP
host TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL DEFAULT 'INBOX',
-- Gmail
refresh_token TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
last_uid INTEGER NOT NULL DEFAULT 0,
last_sync_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- The sweep asks "whose mailboxes are due", so it reads by user.
CREATE INDEX mailboxes_by_user ON mailboxes (user_id);
-- The same mailbox twice is a mistake, not a choice: two rows would make two
-- tasks out of every mail. Gmail's uniqueness is the account, IMAP's is the host
-- and the username together, and `kind` keeps the two from colliding.
CREATE UNIQUE INDEX mailboxes_once ON mailboxes (user_id, kind, host, username);
@@ -0,0 +1,22 @@
-- Room for Gmail beside the others.
--
-- Only the two columns. Moving the data is a separate migration on purpose: the
-- Gmail handlers still read user_settings, and a migration that emptied that table
-- before they were rewritten would disconnect a working mailbox. Additive first,
-- and the move when the code on the other side is ready for it.
--
-- Gmail lived in `user_settings` under scope 'gmail' because it was the only
-- integration of its kind. Now there are two, and two places to look for the same
-- question — which mailboxes does this person have — is one place too many.
--
-- `seen` is Gmail's own deduplication: a list of message ids it has already turned
-- into tasks. IMAP does not need it, because uids are monotonic and `last_uid`
-- answers the same question in one number. Gmail's query is a 30-day window rather
-- than a marker, so the list is what it has.
ALTER TABLE mailboxes ADD COLUMN seen TEXT NOT NULL DEFAULT '[]';
-- What makes a mail into a task: 'starred', or 'label' with the name in `label`.
-- Its own column rather than packed into `label` with a separator — a column that
-- holds two things joined by a colon is a column somebody parses wrongly later,
-- and a label named with a colon in it would do it on the first day.
ALTER TABLE mailboxes ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'starred';
@@ -0,0 +1,12 @@
-- Where a mailbox's tasks land.
--
-- Gmail carried this as `project_id` in its settings blob. It belongs to the
-- mailbox, and it is worth having for the ones read over IMAP too: a work mailbox
-- filing into the work project is the obvious thing to want, and the alternative
-- is everything landing in the inbox to be moved by hand.
--
-- Empty means the person's inbox, which is also what an id that no longer names a
-- project they can write to falls back to. Not a foreign key for that reason: a
-- deleted project should quietly stop being the destination, not refuse the delete
-- or take the mailbox down with it.
ALTER TABLE mailboxes ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,46 @@
-- Gmail's rows move in beside the ones read over IMAP.
--
-- Held back until now on purpose: this empties the settings row, and running it
-- while the handlers still read that row would have disconnected a working
-- mailbox. internal/httpapi now reads the mailboxes table, so the move is safe.
--
-- The values move across still sealed. Both tables seal with the same key and
-- store the same string, so copying the ciphertext verbatim is not a shortcut
-- around the encryption — it is the encryption, untouched. That also means this
-- migration does not need the key, which a migration has no business holding.
--
-- `username` is the address the account reports, and it is what makes the unique
-- index meaningful. It may be empty on rows that never got that far; two such rows
-- cannot exist for one person anyway, because the settings table could only hold
-- one.
INSERT INTO mailboxes (
id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at
)
SELECT
lower(hex(randomblob(16))),
s.user_id,
'gmail',
COALESCE(json_extract(s.values_json, '$.email'), 'Gmail'),
'',
COALESCE(json_extract(s.values_json, '$.email'), ''),
'',
'INBOX',
COALESCE(json_extract(s.values_json, '$.refresh_token'), ''),
COALESCE(json_extract(s.values_json, '$.access_token'), ''),
COALESCE(json_extract(s.values_json, '$.expires_at'), 0),
COALESCE(json_extract(s.values_json, '$.label'), ''),
COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'),
COALESCE(json_extract(s.values_json, '$.seen'), '[]'),
COALESCE(json_extract(s.values_json, '$.project_id'), ''),
0,
0,
unixepoch()
FROM user_settings s
WHERE s.scope = 'gmail'
AND COALESCE(json_extract(s.values_json, '$.refresh_token'), '') <> '';
-- Emptied, not deleted: the row may hold nothing else today, but the table is
-- shared and a DELETE here would be a habit worth not forming.
UPDATE user_settings SET values_json = '{}' WHERE scope = 'gmail';
+88
View File
@@ -0,0 +1,88 @@
-- Notes.
--
-- Markdown in a column, not rich text and not a format of our own. Export was a
-- requirement before a line was written, and if the stored form *is* the exported
-- form there is no conversion left to lose anything in — a note stays readable by
-- any editor on the day this program is gone. It is also what makes full-text
-- search and anything an AI does over notes cheap: both read text.
--
-- Under a project rather than in a folder tree of its own. Projects, groups and
-- labels already exist and are already shared, already have roles, already have a
-- trash. A second hierarchy to file things in is one too many, and a note that
-- belongs to a project inherits every one of those answers instead of needing its
-- own. A note with no project is loose, the way a task in the inbox is.
--
-- `deleted_at` and `created_by` follow tasks exactly, including SET NULL: an
-- account can be removed without taking somebody else's notes with it.
CREATE TABLE notes (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
-- The same Danish transliteration as tasks carry, and for the same reason:
-- unicode61 folds diacritics, so "grøn" is findable as "gron", but it does
-- nothing for æ→ae or å→aa, which is how Danes actually type when the keyboard
-- is not theirs.
fold TEXT GENERATED ALWAYS AS (
replace(replace(replace(replace(replace(replace(
title || ' ' || body,
'ø', 'o'), 'Ø', 'o'),
'æ', 'ae'), 'Æ', 'ae'),
'å', 'aa'), 'Å', 'aa')
) STORED
);
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
-- What a note points at: a task, another note, a project.
--
-- Derived from the body every time it is saved, never edited directly. The text
-- stays the truth; this is an index over it — which is what makes the question
-- worth asking backwards. "What refers to this task" must not mean searching every
-- note's prose, or the panel that shows it would be too slow to leave open.
--
-- `target_id` is deliberately not a foreign key. A link to something that has been
-- deleted is a fact about the note, and it should read as a dead link rather than
-- vanish or block the delete.
CREATE TABLE note_links (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
target_id TEXT NOT NULL,
PRIMARY KEY (note_id, kind, target_id)
);
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
CREATE VIRTUAL TABLE notes_fts USING fts5 (
title,
body,
fold,
id UNINDEXED,
project_id UNINDEXED,
content = 'notes',
content_rowid = 'rowid',
tokenize = 'unicode61 remove_diacritics 2'
);
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts (rowid, title, body, fold, id, project_id)
VALUES (new.rowid, new.title, new.body, new.fold, new.id, new.project_id);
END;
CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts (notes_fts, rowid, title, body, fold, id, project_id)
VALUES ('delete', old.rowid, old.title, old.body, old.fold, old.id, old.project_id);
END;
CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts (notes_fts, rowid, title, body, fold, id, project_id)
VALUES ('delete', old.rowid, old.title, old.body, old.fold, old.id, old.project_id);
INSERT INTO notes_fts (rowid, title, body, fold, id, project_id)
VALUES (new.rowid, new.title, new.body, new.fold, new.id, new.project_id);
END;
@@ -0,0 +1,13 @@
-- The order of the fixed views in the sidebar.
--
-- On the account rather than in the browser, and for the reason the sidebar's
-- width is the other way round: a width is a fact about the screen you are at, and
-- the answer on a phone is not the answer on a monitor. Which order you want I dag,
-- Kommende, Venter and Indbakken in is a fact about you, and it should be the same
-- on the laptop as on the phone.
--
-- A list of keys rather than a number per row, for the same reason sidebar_collapsed
-- is: the set of views changes with the program, and a key that no longer exists
-- costs nothing — the sidebar draws what it recognises and appends anything new at
-- the end. That is what makes adding a view later not a migration.
ALTER TABLE users ADD COLUMN nav_order TEXT NOT NULL DEFAULT '[]';
+312
View File
@@ -0,0 +1,312 @@
package store
import (
"context"
"database/sql"
"regexp"
"strings"
"time"
)
// Note is a page of Markdown belonging to a project, or to nobody.
type Note struct {
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Links is what the body points at, read out of the text on the way in. Never
// set by a caller: it is derived, and a caller that set it would be describing
// a note whose text says something else.
Links []NoteLink `json:"links,omitempty"`
}
// NoteLink is one reference from a note to something else.
type NoteLink struct {
Kind string `json:"kind"` // "task", "note" or "project"
TargetID string `json:"target_id"`
}
const noteColumns = `id, project_id, title, body, created_by, pinned,
created_at, updated_at, deleted_at`
// Reference syntax, and deliberately the same characters the quick-add parser
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
// into the task box — not a second kind of tag that happens to look alike. That
// is the whole of "tag a note and see it in the project".
var (
projectRef = regexp.MustCompile(`(?:^|\s)#([\p{L}\p{N}_-]{1,64})`)
// [[a note]] by title, the spelling every note-taking program has settled on.
noteRef = regexp.MustCompile(`\[\[([^\]\n]{1,200})\]\]`)
// A task by id, which is what a link pasted out of the app carries.
taskRef = regexp.MustCompile(`(?:/opgave/|task:)([0-9a-fA-F-]{36})`)
)
// SaveNote writes a note and rebuilds what it points at.
//
// The links are torn down and rebuilt rather than diffed. A note's body is small,
// the set is small, and a diff would be a second description of the same fact —
// one that can disagree with the text, which is the one thing this must never do.
func (db *DB) SaveNote(ctx context.Context, n *Note) error {
if n.ID == "" {
n.ID = NewID()
}
now := time.Now()
if n.CreatedAt.IsZero() {
n.CreatedAt = now
}
n.UpdatedAt = now
// The title is always the first line, the way Apple Notes does it. Not "when it
// is empty": derived once and then left alone, it goes stale the moment somebody
// rewrites the opening of a note, and the list ends up calling a note by a name
// that is nowhere in it. The column is a cache of the body, not a field beside
// it, and every caller gets the same answer because none of them can set it.
n.Title = firstLine(n.Body)
return db.Tx(ctx, func(tx *sql.Tx) error {
var projectID any
if n.ProjectID != "" {
projectID = n.ProjectID
}
var createdBy any
if n.CreatedBy != "" {
createdBy = n.CreatedBy
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO notes (id, project_id, title, body, created_by, pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
project_id = excluded.project_id,
title = excluded.title,
body = excluded.body,
pinned = excluded.pinned,
updated_at = excluded.updated_at`,
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM note_links WHERE note_id = ?`, n.ID); err != nil {
return err
}
n.Links = LinksIn(n.Body)
for _, l := range n.Links {
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO note_links (note_id, kind, target_id) VALUES (?, ?, ?)`,
n.ID, l.Kind, l.TargetID); err != nil {
return err
}
}
return nil
})
}
// LinksIn reads the references out of a body.
//
// Exported because it is worth testing on its own, and because the editor wants
// the same answer while somebody is typing rather than after they have saved.
func LinksIn(body string) []NoteLink {
seen := map[NoteLink]bool{}
var out []NoteLink
add := func(kind, target string) {
l := NoteLink{Kind: kind, TargetID: target}
if target == "" || seen[l] {
return
}
seen[l] = true
out = append(out, l)
}
for _, m := range taskRef.FindAllStringSubmatch(body, -1) {
add("task", strings.ToLower(m[1]))
}
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
add("project", m[1])
}
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
add("note", strings.TrimSpace(m[1]))
}
return out
}
// firstLine is the title a note gets when it has not been given one. Markdown
// heading marks are stripped: "# Møde" is titled "Møde", not "# Møde".
func firstLine(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
if line != "" {
if len([]rune(line)) > 120 {
return string([]rune(line)[:120])
}
return line
}
}
return ""
}
// Note returns one, or nil when it is not there or is in the trash.
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
row := db.QueryRowContext(ctx,
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
n, err := scanNote(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
links, err := db.linksOf(ctx, n.ID)
if err != nil {
return nil, err
}
n.Links = links
return &n, nil
}
// NotesInProject lists a project's notes, pinned first and newest after.
func (db *DB) NotesInProject(ctx context.Context, projectID string) ([]Note, error) {
return db.notesWhere(ctx,
`WHERE project_id = ? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`,
projectID)
}
// LooseNotes are the ones filed under no project — a person's own, the way a task
// in the inbox is. Nobody else can see them, so there is no role to ask about.
func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE project_id IS NULL AND created_by = ? AND deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC`, userID)
}
// AllNotes is everything a person can see: their own loose ones, and the ones in
// projects they are part of.
//
// This is what the Notes page lists. Filing a note in a project is how it gets
// shared, and a list that dropped it at that moment would make sharing look like
// deleting — which is exactly how it read before this existed.
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND (
(project_id IS NULL AND created_by = ?)
OR project_id IN (
SELECT id FROM projects WHERE owner_id = ? AND deleted_at IS NULL
UNION
SELECT project_id FROM project_members WHERE user_id = ?
)
)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
}
// NotesLinking answers the backwards question: what points at this thing.
//
// The reason note_links exists. Without it this would mean reading every note's
// prose, and a panel that slow is one nobody leaves open.
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
ORDER BY updated_at DESC`, kind, targetID)
}
// SearchNotes matches title and body, in either spelling of a Danish word.
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
if limit <= 0 {
limit = 50
}
expr := MatchExprOver(query, "title", "body")
if expr == "" {
return nil, nil
}
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
// on it would work and read the whole index to do it.
return db.notesWhere(ctx, `
WHERE deleted_at IS NULL
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
ORDER BY updated_at DESC
LIMIT ?`, expr, limit)
}
// DeleteNote puts it in the trash. Nothing here removes a row.
func (db *DB) DeleteNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().Unix(), time.Now().Unix(), id)
return err
}
// RestoreNote takes it back out.
func (db *DB) RestoreNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
time.Now().Unix(), id)
return err
}
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
rows, err := db.QueryContext(ctx,
`SELECT kind, target_id FROM note_links WHERE note_id = ? ORDER BY kind, target_id`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NoteLink
for rows.Next() {
var l NoteLink
if err := rows.Scan(&l.Kind, &l.TargetID); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func scanNote(row scanner) (Note, error) {
var n Note
var projectID, createdBy sql.NullString
var created, updated int64
var deleted sql.NullInt64
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
&n.Pinned, &created, &updated, &deleted); err != nil {
return n, err
}
n.ProjectID = projectID.String
n.CreatedBy = createdBy.String
n.CreatedAt = time.Unix(created, 0)
n.UpdatedAt = time.Unix(updated, 0)
if deleted.Valid {
t := time.Unix(deleted.Int64, 0)
n.DeletedAt = &t
}
return n, nil
}
+291
View File
@@ -0,0 +1,291 @@
package store
import (
"context"
"strings"
"testing"
)
// The point of the whole thing: a #tag in a note is the same tag as in a task.
// Not a second kind that looks alike and has to be kept in step by hand.
func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{
CreatedBy: userID,
Body: "Mødet med #Firma gik fint. Følg op sammen med #Regnskab.",
}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
var projects []string
for _, l := range n.Links {
if l.Kind == "project" {
projects = append(projects, l.TargetID)
}
}
if len(projects) != 2 {
t.Fatalf("read %v out of the note, want the two projects", n.Links)
}
if projects[0] != "Firma" || projects[1] != "Regnskab" {
t.Errorf("got %v", projects)
}
}
// Backwards is the direction that makes it a second brain rather than a folder.
func TestANoteIsFoundFromWhatItPointsAt(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const taskID = "01a01694-a25c-7000-a50f-d72ab33f9b6a"
notes := []*Note{
{CreatedBy: userID, Body: "Aftalt på mødet, se /opgave/" + taskID},
{CreatedBy: userID, Body: "Intet at gøre med den sag."},
}
for _, n := range notes {
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
}
found, err := db.NotesLinking(ctx, "task", taskID)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Fatalf("%d notes point at the task, want 1", len(found))
}
if !strings.HasPrefix(found[0].Title, "Aftalt") {
t.Errorf("the wrong note came back: %q", found[0].Title)
}
}
// The links are an index over the text, so editing the text must move them.
// A stale link is worse than none: it says a connection exists that does not.
func TestEditingANoteRewritesWhatItPointsAt(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Handler om #Firma"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if before, _ := db.NotesLinking(ctx, "project", "Firma"); len(before) != 1 {
t.Fatal("the first save did not record the link")
}
n.Body = "Handler om #Regnskab i stedet"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
stale, err := db.NotesLinking(ctx, "project", "Firma")
if err != nil {
t.Fatal(err)
}
if len(stale) != 0 {
t.Errorf("the note still points at Firma after the mention was removed")
}
if fresh, _ := db.NotesLinking(ctx, "project", "Regnskab"); len(fresh) != 1 {
t.Errorf("the new mention was not recorded")
}
}
// A list of untitled notes is a list nobody can read.
func TestANoteTakesItsTitleFromItsFirstLine(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
for _, c := range []struct{ body, want string }{
{"# Møde med Anders\n\nHan ville gerne...", "Møde med Anders"},
{"\n\n Løse tanker \nmere", "Løse tanker"},
{"", ""},
} {
n := &Note{CreatedBy: userID, Body: c.body}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != c.want {
t.Errorf("body %q gave title %q, want %q", c.body, n.Title, c.want)
}
}
}
// Danish spelling, both ways round — the same generosity tasks already get.
func TestNotesAreFoundInEitherSpelling(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
// The title is the first line now, so that is where it goes.
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
}); err != nil {
t.Fatal(err)
}
for _, q := range []string{"grønt", "gront", "århus", "aarhus", "regnskab"} {
found, err := db.SearchNotes(ctx, q, 10)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Errorf("%q found %d notes, want 1", q, len(found))
}
}
}
// Deleting is the trash, the way it is everywhere else in this program. A note is
// the one place somebody can lose an hour of writing to one keystroke.
func TestADeletedNoteCanComeBack(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Vigtigt"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if err := db.DeleteNote(ctx, n.ID); err != nil {
t.Fatal(err)
}
if gone, _ := db.Note(ctx, n.ID); gone != nil {
t.Error("a deleted note is still returned")
}
if err := db.RestoreNote(ctx, n.ID); err != nil {
t.Fatal(err)
}
back, err := db.Note(ctx, n.ID)
if err != nil {
t.Fatal(err)
}
if back == nil || back.Body != "Vigtigt" {
t.Error("the note did not come back whole")
}
}
func TestWhatIsReadOutOfABody(t *testing.T) {
for _, c := range []struct {
name, body string
want []NoteLink
}{
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
{"the same tag twice is one link", "#Firma og #Firma igen",
[]NoteLink{{"project", "Firma"}}},
{"a heading is not a tag", "# Overskrift", nil},
} {
t.Run(c.name, func(t *testing.T) {
got := LinksIn(c.body)
if len(got) != len(c.want) {
t.Fatalf("got %v, want %v", got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("got %v, want %v", got[i], c.want[i])
}
}
})
}
}
// The number beside a project is what is left to do: not finished, not deleted.
// It replaced a count of people, which said 2 on an empty project.
func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
p := &Project{Name: "Tallene", OwnerID: userID}
if err := db.CreateProject(ctx, p); err != nil {
t.Fatal(err)
}
count := func() int {
list, err := db.ListProjects(ctx, userID, false)
if err != nil {
t.Fatal(err)
}
for _, x := range list {
if x.ID == p.ID {
return x.OpenCount
}
}
t.Fatal("the project is not in the list")
return -1
}
if n := count(); n != 0 {
t.Errorf("an empty project counts %d", n)
}
var ids []string
for _, c := range []string{"en", "to", "tre"} {
task := &Task{ProjectID: p.ID, Content: c, CreatedBy: userID}
if err := db.CreateTask(ctx, task, nil); err != nil {
t.Fatal(err)
}
ids = append(ids, task.ID)
}
if n := count(); n != 3 {
t.Errorf("three tasks count %d", n)
}
if _, err := db.CompleteTask(ctx, ids[0], userID); err != nil {
t.Fatal(err)
}
if n := count(); n != 2 {
t.Errorf("after finishing one, the count is %d, want 2", n)
}
if err := db.DeleteTask(ctx, ids[1]); err != nil {
t.Fatal(err)
}
if n := count(); n != 1 {
t.Errorf("after deleting one, the count is %d, want 1", n)
}
}
// The title follows the first line, always — not only the first time.
//
// Derived once and then left alone, it goes stale the moment somebody rewrites
// the opening, and the list ends up calling a note by a name that is nowhere in
// it. That is exactly what happened: a note headed "Ny note" was listed as
// "fdgfgfgh", which was what its first line had been some edits ago.
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Første udgave" {
t.Fatalf("title started as %q", n.Title)
}
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("after rewriting the first line the title is %q", n.Title)
}
back, err := db.Note(ctx, n.ID)
if err != nil {
t.Fatal(err)
}
if back.Title != "Ny note" {
t.Errorf("the stored title is %q", back.Title)
}
// And a title cannot be set beside the text, because then the two can disagree.
n.Title = "noget andet"
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
if n.Title != "Ny note" {
t.Errorf("a title set by hand survived as %q", n.Title)
}
}
+70 -40
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/kristianwind/verdande/internal/auth"
@@ -271,48 +272,69 @@ func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error)
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token)
}
// UsersWithGmail returns everybody who has connected a mailbox, which is who the
// poller has to visit. Selected by the presence of a refresh token rather than by a
// flag, so a disconnected account drops out of the sweep the moment its tokens are
// cleared.
func (db *DB) UsersWithGmail(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id FROM users u
JOIN user_settings s ON s.user_id = u.id
WHERE s.scope = 'gmail' AND s.values_json LIKE '%"refresh_token":"%'`)
if err != nil {
return nil, err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
out := make([]User, 0, len(ids))
for _, id := range ids {
user, err := db.UserByID(ctx, id)
if err != nil {
continue
}
out = append(out, *user)
}
return out, nil
}
// --- per-user settings ------------------------------------------------------------------
// UserSettings reads one scope of a person's integration settings. A missing row is
// an empty map, not an error: nobody has settings until they set some.
// --- sealed values ----------------------------------------------------------------
// secretFields are the keys whose values never leave this process in the clear.
//
// A named set rather than "seal everything": the rest of a settings blob is what
// somebody reads when a mailbox misbehaves — which folder, which label, which
// address — and turning all of it into ciphertext would buy nothing and cost the
// ability to look.
var secretFields = map[string]bool{
"refresh_token": true,
"access_token": true,
"password": true,
"client_secret": true,
}
// seal returns a copy of values with the secret fields sealed. Without a key it
// returns them untouched, which is what the tests that never set one expect.
func (db *DB) seal(values map[string]any) (map[string]any, error) {
if db.box == nil {
return values, nil
}
out := make(map[string]any, len(values))
for k, v := range values {
s, ok := v.(string)
if !secretFields[k] || !ok {
out[k] = v
continue
}
sealed, err := db.box.Seal(s)
if err != nil {
return nil, fmt.Errorf("seal %s: %w", k, err)
}
out[k] = sealed
}
return out, nil
}
// unseal is seal's other direction. Values written before there was a key come
// back as themselves; the next write puts them away properly, so the column
// converts itself as it is used rather than in one migration that has to be right
// about every row at once.
func (db *DB) unseal(values map[string]any) (map[string]any, error) {
if db.box == nil {
return values, nil
}
for k, v := range values {
s, ok := v.(string)
if !secretFields[k] || !ok {
continue
}
plain, err := db.box.Unseal(s)
if err != nil {
return nil, fmt.Errorf("read %s: %w", k, err)
}
values[k] = plain
}
return values, nil
}
func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) {
var raw string
err := db.QueryRowContext(ctx,
@@ -329,10 +351,14 @@ func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[strin
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
return db.unseal(values)
}
func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error {
values, err := db.seal(values)
if err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return err
@@ -370,10 +396,14 @@ func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]an
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
return db.unseal(values)
}
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
values, err := db.seal(values)
if err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return err
+206
View File
@@ -0,0 +1,206 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// A Passkey is one credential on one device.
//
// One row per credential rather than per account: a person has a laptop and a
// phone, and losing one must not mean losing the way in. The name is theirs to
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
// read is a list nobody revokes from.
type Passkey struct {
ID string
UserID string
CredentialID string
PublicKey []byte
AAGUID string
SignCount uint32
Discoverable bool
UserVerified bool
Name string
CreatedAt time.Time
LastUsedAt time.Time
}
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
if p.ID == "" {
p.ID = NewID()
}
p.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx, `
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
return err
}
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPasskeys(rows)
}
// PasskeyByCredentialID is how a login finds the account, before anybody has said
// who they are. The credential id is unique across the instance, which is what
// makes that possible.
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE credential_id = ?`, credentialID)
if err != nil {
return nil, err
}
defer rows.Close()
list, err := scanPasskeys(rows)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, ErrNotFound
}
return &list[0], nil
}
// TouchPasskey records a use and the authenticator's counter.
//
// The counter is stored to be compared. One that goes backwards means the
// credential has been cloned, which is the single thing this design cannot
// otherwise detect — and not every authenticator keeps one, so zero means "not
// offered" rather than "suspicious".
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
_, err := db.ExecContext(ctx,
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
signCount, time.Now().Unix(), id)
return err
}
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
res, err := db.ExecContext(ctx,
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
// id read in one step and deleted in the next is a window, and the reason somebody
// is on this screen at all is usually that they think a device is gone.
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CountPasskeys is what stops somebody removing their last way in when it is the
// only one they have.
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
return n, err
}
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
out := []Passkey{}
for rows.Next() {
var p Passkey
var discoverable, verified int
var created, used int64
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
return nil, err
}
p.Discoverable = discoverable == 1
p.UserVerified = verified == 1
p.CreatedAt = time.Unix(created, 0).UTC()
if used > 0 {
p.LastUsedAt = time.Unix(used, 0).UTC()
}
out = append(out, p)
}
return out, rows.Err()
}
// --- challenges -------------------------------------------------------------------
// StoreChallenge keeps the server's side of one ceremony.
//
// Server-side rather than in a cookie: the whole point of a challenge is that the
// server chose it and remembers choosing it. It is deleted when answered and swept
// when it expires — a challenge that outlives its ceremony is a replay waiting to
// be tried.
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
id := NewID()
now := time.Now()
_, err := db.ExecContext(ctx, `
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
return id, err
}
// TakeChallenge reads a challenge and removes it in the same transaction.
//
// Taken rather than read, because a challenge is answerable exactly once. Two
// requests racing the same one would otherwise both succeed, which is the whole
// shape of a replay.
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
err = db.Tx(ctx, func(tx *sql.Tx) error {
var owner sql.NullString
var expires int64
row := tx.QueryRowContext(ctx,
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
id, purpose)
if err := row.Scan(&owner, &session, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
return err
}
if time.Now().Unix() > expires {
return ErrNotFound
}
userID = owner.String
return nil
})
return userID, session, err
}
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
// itself; an abandoned one is what this is for.
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+16 -1
View File
@@ -30,6 +30,11 @@ type Project struct {
Role Role
// MemberCount is how many people can see it; 1 means it is not shared.
MemberCount int
// OpenCount is what is left to do: not deleted, not finished. It is the number
// the sidebar shows, because a number beside a project in a task app is read as
// tasks whatever it was meant to be.
OpenCount int
}
type Section struct {
@@ -50,6 +55,13 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
p.archived, p.sort_order, p.created_at, p.updated_at,
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count,
-- What is left to do, which is what the number beside a project in the
-- sidebar means to the person reading it. It used to show member_count,
-- and a "2" on an empty project read as two tasks to everybody who saw
-- it — including the person who wrote the app.
(SELECT count(*) FROM tasks t
WHERE t.project_id = p.id AND t.deleted_at IS NULL AND t.completed_at IS NULL
) AS open_count,
CASE WHEN p.owner_id = ? THEN p.group_id END AS group_id
FROM projects p
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
@@ -88,6 +100,9 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
p.archived, p.sort_order, p.created_at, p.updated_at,
? AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1,
(SELECT count(*) FROM tasks t
WHERE t.project_id = p.id AND t.deleted_at IS NULL AND t.completed_at IS NULL
),
CASE WHEN p.owner_id = ? THEN p.group_id END
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), userID, projectID)
if err != nil {
@@ -113,7 +128,7 @@ func scanProject(rows *sql.Rows) (Project, error) {
var role string
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &groupID)
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &p.OpenCount, &groupID)
if err != nil {
return p, err
}
+40
View File
@@ -0,0 +1,40 @@
package store
import (
"context"
"testing"
)
// Saving a mailbox that is already there must update it, not fail.
//
// The row is written again on every token refresh and every time Gmail's seen
// list changes. If that write errors, the tasks a sync made are kept and the note
// of having made them is not — so the same mail becomes a task again on the next
// sweep, once every ten minutes, forever.
func TestSavingAnExistingMailboxUpdatesIt(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
m := &Mailbox{UserID: userID, Kind: "gmail", Username: "[email protected]",
RefreshToken: "r", Seen: []string{"a"}}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
m.Seen = []string{"a", "b"}
m.AccessToken = "fresh"
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatalf("saving it a second time failed: %v", err)
}
back, err := db.MailboxOfKind(ctx, userID, "gmail")
if err != nil {
t.Fatal(err)
}
if len(back.Seen) != 2 {
t.Errorf("the seen list came back as %v", back.Seen)
}
if back.AccessToken != "fresh" {
t.Errorf("the token came back as %q", back.AccessToken)
}
}
+12 -2
View File
@@ -36,8 +36,18 @@ func FoldDanish(s string) string {
// Returns "" when there is nothing searchable in the input; callers should treat
// that as "no query" rather than as "match everything".
func MatchExpr(query string) string {
return MatchExprOver(query, "content", "description")
}
// MatchExprOver is MatchExpr against a named set of columns.
//
// The columns have to be named because the index they are searched in decides
// them: tasks carry content and description, notes carry title and body. Passing
// one table's column names to the other's index is not a wrong answer but an
// error — "no such column" — which is at least loud.
func MatchExprOver(query string, columns ...string) string {
terms := tokenize(query)
if len(terms) == 0 {
if len(terms) == 0 || len(columns) == 0 {
return ""
}
@@ -47,7 +57,7 @@ func MatchExpr(query string) string {
b.WriteString(" AND ")
}
folded := FoldDanish(term)
b.WriteString(`({content description} : `)
b.WriteString(`({` + strings.Join(columns, " ") + `} : `)
b.WriteString(quote(term))
b.WriteString(`* OR fold : `)
b.WriteString(quote(folded))
+126
View File
@@ -0,0 +1,126 @@
package store
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/kristianwind/verdande/internal/secret"
)
// The whole point of the exercise: what is written must not be readable in the
// file that a backup copies.
func TestATokenIsNotStoredInTheClear(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const token = "1//0-a-real-looking-refresh-token"
if err := db.SetUserSettings(ctx, userID, "gmail", map[string]any{
"refresh_token": token,
"label": "Verdande",
}); err != nil {
t.Fatal(err)
}
var raw string
if err := db.QueryRowContext(ctx,
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, token) {
t.Fatalf("the token is in the row as written: %s", raw)
}
// The rest stays readable, which is the reason for naming the secret fields
// rather than sealing the whole blob.
if !strings.Contains(raw, "Verdande") {
t.Errorf("the label was sealed too: %s", raw)
}
back, err := db.UserSettings(ctx, userID, "gmail")
if err != nil {
t.Fatal(err)
}
if back["refresh_token"] != token {
t.Errorf("got %q back", back["refresh_token"])
}
}
// The migration that matters. A row written before there was a key must still be
// readable, and must put itself away sealed the next time it is written — nobody
// should have to reconnect a mailbox because the server learned to encrypt.
func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const token = "1//0-written-when-there-was-no-key"
plain, _ := json.Marshal(map[string]any{"refresh_token": token})
if _, err := db.ExecContext(ctx, `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)`, userID, string(plain)); err != nil {
t.Fatal(err)
}
values, err := db.UserSettings(ctx, userID, "gmail")
if err != nil {
t.Fatalf("a row from before the key could not be read: %v", err)
}
if values["refresh_token"] != token {
t.Fatalf("got %q, want the plain token back", values["refresh_token"])
}
// Written back — and now sealed, without anything having asked for it.
if err := db.SetUserSettings(ctx, userID, "gmail", values); err != nil {
t.Fatal(err)
}
var raw string
if err := db.QueryRowContext(ctx,
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, token) {
t.Error("the row is still in the clear after being written back")
}
}
// The sweep finds people by the presence of a mailbox, so it must keep working
// now that the credential in it is ciphertext.
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SaveMailbox(ctx, &Mailbox{
UserID: userID, Kind: "gmail", RefreshToken: "1//0-token",
}); err != nil {
t.Fatal(err)
}
users, err := db.UsersWithMailboxes(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 1 {
t.Fatalf("the sweep found %d people with mailboxes, want 1", len(users))
}
}
func sealedStore(t *testing.T) (*DB, string) {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
box, err := secret.Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
db.UseSecrets(box)
user := &User{Email: "[email protected]", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
+13
View File
@@ -7,6 +7,7 @@ import (
"database/sql"
"embed"
"fmt"
"github.com/kristianwind/verdande/internal/secret"
"io/fs"
"path/filepath"
"runtime"
@@ -23,8 +24,20 @@ var migrationFS embed.FS
type DB struct {
*sql.DB
path string
// box seals the values that must not travel with a backup. Nil in the tests
// that do not care, where a secret is stored as itself — see Seal.
box *secret.Box
}
// UseSecrets gives the store the key it seals with. Called once at startup, before
// anything reads or writes settings.
//
// Separate from Open because the key comes from the environment or from a file
// beside the data, and a database that cannot be opened without it would make a
// misplaced key look like a corrupt disk.
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
// dsn is the connection string.
//
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
+33 -5
View File
@@ -36,8 +36,13 @@ type User struct {
// is as true on the laptop as on the desktop. The sidebar's *width* is the
// opposite case and is stored per browser.
SidebarCollapsed []string
CreatedAt time.Time
UpdatedAt time.Time
// NavOrder is the order this person wants the fixed views in, by key. Empty
// means the order the program ships with. Unknown keys are ignored and missing
// ones are appended, so adding a view later is not a migration.
NavOrder []string
CreatedAt time.Time
UpdatedAt time.Time
}
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
@@ -104,7 +109,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
}
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
var u User
@@ -112,11 +117,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
var totpEnabled, isAdmin int
var created, updated int64
var collapsed string
var collapsed, navOrder string
err := db.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -131,11 +136,34 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
// worst case is a sidebar that opens fully, which is the state it shipped in.
u.SidebarCollapsed = []string{}
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
u.NavOrder = []string{}
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
u.CreatedAt = time.Unix(created, 0).UTC()
u.UpdatedAt = time.Unix(updated, 0).UTC()
return &u, nil
}
// SetNavOrder records the order of the fixed views.
func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error {
if order == nil {
order = []string{}
}
raw, err := json.Marshal(order)
if err != nil {
return err
}
res, err := db.ExecContext(ctx,
`UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`,
string(raw), time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetSidebarCollapsed records which sidebar headings are folded.
//
// Its own method rather than a field on UpdateProfile: that one backs a form with
+2
View File
@@ -48,12 +48,14 @@ nav:
- As a Rune: rune.md
- Configuration: configuration.md
- Using it:
- Signing in: signing-in.md
- Quick add: quick-add.md
- Filters: filters.md
- Importing from Todoist: import.md
- Connecting things:
- Calendar and CalDAV: caldav.md
- Mail to task: mail.md
- Mailboxes: mailboxes.md
- Claude (MCP): mcp.md
- AI features: ai.md
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
+28 -1
View File
@@ -11,7 +11,11 @@ gameskill:
invites and reminders, then start it and open the URL to create the first
admin account.
author: "kristianwind"
version: 2
# Bumped whenever the variables below change. The panel keys its catalogue on
# this: a manifest with new fields and an unchanged version is a manifest the
# panel has no reason to re-read, so the fields never appear and the failure is
# silent — the server's settings page simply does not offer them.
version: 4
icon: "app"
# The image is distroless — no shell — so keep_entrypoint runs the binary the
@@ -78,6 +82,29 @@ gameskill:
type: bool
default: false
# Restarting from verdande's own settings, which is also how it picks up a new
# version: a container cannot replace its own image, so the panel recreates it
# and pulls :latest on the way. All three are needed together; the page names
# whichever are missing.
- key: VERDANDE_PANEL_URL
name: "Panel address, so verdande can ask to be restarted from its own settings"
type: string
default: ""
- key: VERDANDE_PANEL_TOKEN
name: "Panel API token — from an account with control of this server"
type: string
secret: true
default: ""
- key: VERDANDE_PANEL_SERVER_ID
name: "This server's id in the panel (the one in its URL)"
type: string
default: ""
- key: VERDANDE_SECRET_KEY
name: "Key for the tokens and mailbox passwords in the database — 32 bytes of base64, from: openssl rand -base64 32. Leave empty to keep one in a file beside the data. Change it and every mailbox must be connected again."
type: string
secret: true
default: ""
install:
# The image has no shell, so the install step borrows one. It only has to make
# the directory layout exist; verdande creates and migrates its own database on
+281 -12
View File
@@ -6,13 +6,18 @@
<title>verdande — self-hosted tasks, shared</title>
<meta
name="description"
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. CalDAV, mail-to-task and an MCP connector for Claude."
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
/>
<meta name="theme-color" content="#111312" />
<meta property="og:title" content="verdande" />
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
<meta property="og:type" content="website" />
<meta property="og:image" content="screenshots/today.webp" />
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
<!-- Summary rather than the large card: the screenshot is a dense interface,
and shrunk into a wide banner it reads as grey noise. -->
<meta name="twitter:card" content="summary" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
@@ -38,6 +43,10 @@
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
}
/* Written twice on purpose. The media query is what a first-time visitor
gets; the attribute is what somebody who has chosen gets, and it has to
win in both directions — a chosen light theme on a dark system as well
as the other way round. A single rule cannot do both. */
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--ground: #faf9f7;
@@ -53,6 +62,63 @@
}
}
:root[data-theme='light'] {
--ground: #faf9f7;
--surface: #ffffff;
--surface-raised: #ffffff;
--line: #e5e3de;
--line-strong: #d3d0c9;
--ink: #1c201e;
--ink-muted: #5f6763;
--ink-faint: #8b918d;
--accent: #2f8f63;
--accent-ink: #ffffff;
}
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 10;
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.theme-toggle:hover {
color: var(--ink);
border-color: var(--line-strong);
}
/* Whichever screenshot does not belong to the theme in force is simply not
there. Both are in the markup so neither has to be fetched at the moment
somebody flips the switch. */
:root:not([data-theme='light']) .only-light,
:root[data-theme='dark'] .only-light {
display: none;
}
:root[data-theme='light'] .only-dark {
display: none;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) .only-light {
display: block;
}
:root:not([data-theme='dark']) .only-dark {
display: none;
}
}
*,
*::before,
*::after {
@@ -339,6 +405,89 @@
text-align: center;
}
/* --- zoom -------------------------------------------------------------
A screenshot of an interface is unreadable at 280px in a three-column
grid, which is where most of them sit. Clicking one opens it at the
size it was taken.
<dialog> rather than a hand-built overlay: Escape closes it, focus is
trapped, the rest of the page is inert, and the browser does all three
without a line of script. */
.shot img {
cursor: zoom-in;
}
/* Sized so the whole thing fits: a screenshot is 1.55 times wider than it
is tall, so on a laptop the height is what runs out first. Left to grow,
it pushed the caption and the close button off the top of the screen —
and the button is the visible way out. */
dialog.zoom {
max-width: 96vw;
max-height: 100vh;
/* Wraps the image rather than the viewport. With a plain `auto` the box
stayed full width, so the screenshot sat against the left edge and
the close button ended up in the far corner, nowhere near it. */
width: fit-content;
margin: auto;
padding: 2.5rem 0 0;
border: 0;
background: transparent;
overflow: visible;
}
dialog.zoom::backdrop {
background: rgb(0 0 0 / 0.82);
}
dialog.zoom img {
max-width: 96vw;
max-height: 78vh;
width: auto;
height: auto;
display: block;
border-radius: var(--radius-lg);
cursor: zoom-out;
}
dialog.zoom figcaption {
margin-top: 0.75rem;
font-size: 0.8125rem;
color: #a8b0ac;
text-align: center;
}
/* The whole backdrop closes, so the button is a visible affordance rather
than the only way out. */
dialog.zoom .close {
position: absolute;
top: 0;
right: 0;
background: none;
border: 0;
color: #a8b0ac;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
dialog.zoom .close:hover {
color: #fff;
}
@media (prefers-reduced-motion: no-preference) {
dialog.zoom[open] {
animation: zoom-in 0.15s ease-out;
}
@keyframes zoom-in {
from {
opacity: 0;
transform: scale(0.98);
}
}
}
.shots {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -375,6 +524,9 @@
</head>
<body>
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
<span aria-hidden="true">◐</span>
</button>
<div class="wrap">
<header>
<span class="mark" aria-hidden="true">ᚹ</span>
@@ -434,9 +586,9 @@
<section>
<h2>What it looks like</h2>
<figure class="shot">
<figure class="shot only-dark">
<img
src="screenshots/today.png"
src="screenshots/today.webp"
alt="The Today view: a sidebar of projects, filters and labels beside a list of tasks due today, each with its priority ring, time and labels."
width="1400"
height="900"
@@ -445,10 +597,21 @@
<figcaption>Today. Dark is the default; there is a light theme too.</figcaption>
</figure>
<figure class="shot only-light">
<img
src="screenshots/today-light.webp"
alt="The Today view in the light theme: a sidebar of projects, filters and labels beside a list of tasks due today."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Today. Dark is the default; there is a light theme too.</figcaption>
</figure>
<div class="shots">
<figure class="shot">
<img
src="screenshots/board.png"
src="screenshots/board.webp"
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
width="1400"
height="900"
@@ -458,7 +621,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/today-light.png"
src="screenshots/today-light.webp"
alt="The same Today view in the light theme."
width="1400"
height="900"
@@ -468,7 +631,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/week.png"
src="screenshots/week.webp"
alt="Upcoming as a single week: seven columns of days, each holding the tasks due that day."
width="1400"
height="900"
@@ -478,7 +641,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/detail.png"
src="screenshots/detail.webp"
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
width="1400"
height="900"
@@ -488,7 +651,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/themes.png"
src="screenshots/themes.webp"
alt="The settings page showing the five themes as swatches."
width="1400"
height="900"
@@ -498,7 +661,7 @@
</figure>
<figure class="shot">
<img
src="screenshots/history.png"
src="screenshots/history.webp"
alt="The instance-wide history page: who did what, in which project, filtered by person and event."
width="1400"
height="900"
@@ -541,10 +704,20 @@
</p>
</div>
<div class="cell">
<h3>Mail to task</h3>
<h3>Your mail, as an inbox</h3>
<p>
Forward an email to your own address and it becomes a task, subject
parsed like anything else you type.
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
a task. Forward one to your own address instead and the subject is
parsed like anything else you type. One way: unstarring it later does
nothing to the task you made.
</p>
</div>
<div class="cell">
<h3>Nothing to remember</h3>
<p>
Start typing anywhere and you are writing a task, with the letter you
pressed already in the field. Sign in with a passkey and there is no
password to forget either.
</p>
</div>
<div class="cell">
@@ -554,6 +727,22 @@
round trip is tested, not promised.
</p>
</div>
<div class="cell">
<h3>Keys stay behind</h3>
<p>
Mail tokens and mailbox passwords are encrypted in the database, and the
key lives in a file beside it — not inside it. So a backup you download,
or one that ends up somewhere it should not, does not open your mail.
</p>
</div>
<div class="cell">
<h3>Who did what</h3>
<p>
Every change is written down — who moved it, who closed it, who let
somebody in. Delete the account and the trail stays: it is the
instance's record, not the person's.
</p>
</div>
</div>
</section>
@@ -605,5 +794,85 @@
</p>
</footer>
</div>
<!-- Zoom. Added by script rather than written into the markup, so a page with
no JavaScript is exactly what it was before: the screenshots are still
there, still readable, just not clickable. -->
<dialog class="zoom" aria-label="Screenshot, enlarged">
<button class="close" type="button" aria-label="Close">&times;</button>
<img alt="" />
<figcaption></figcaption>
</dialog>
<script>
// Runs before the zoom script and before anything is painted below it, so
// a chosen theme does not flash the other one first.
(function () {
var root = document.documentElement;
var stored = null;
try {
stored = localStorage.getItem('verdande-site-theme');
} catch (e) {
// Private browsing. The choice simply will not be remembered.
}
if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
document.querySelector('.theme-toggle').addEventListener('click', function () {
// What is on the screen right now, whether it came from a choice or
// from the system — otherwise the first press does nothing visible
// for somebody whose system already says light.
var showing =
root.getAttribute('data-theme') ||
(matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
var next = showing === 'light' ? 'dark' : 'light';
root.setAttribute('data-theme', next);
try {
localStorage.setItem('verdande-site-theme', next);
} catch (e) {}
});
})();
(function () {
var dialog = document.querySelector('dialog.zoom');
if (!dialog || typeof dialog.showModal !== 'function') return;
var big = dialog.querySelector('img');
var caption = dialog.querySelector('figcaption');
function open(img) {
big.src = img.currentSrc || img.src;
big.alt = img.alt;
var fig = img.closest('figure');
var text = fig && fig.querySelector('figcaption');
caption.textContent = text ? text.textContent : '';
dialog.showModal();
}
document.querySelectorAll('.shot img').forEach(function (img) {
// Focusable and operable from the keyboard: a control that only a
// mouse can reach is not a control everybody has.
img.tabIndex = 0;
img.setAttribute('role', 'button');
img.addEventListener('click', function () { open(img); });
img.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open(img);
}
});
});
dialog.addEventListener('click', function (event) {
// The backdrop is the dialog itself; anything inside it is not.
if (event.target === dialog || event.target === big ||
event.target.classList.contains('close')) {
dialog.close();
}
});
// Let go of the image when it closes, so a large screenshot is not
// held in memory for the rest of the visit.
dialog.addEventListener('close', function () { big.removeAttribute('src'); });
})();
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+578 -34
View File
@@ -50,9 +50,16 @@ function watchForTrouble(page) {
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (url.includes('/api/') && !response.ok()) {
trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
}
if (!url.includes('/api/') || response.ok()) return;
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
// which is the correct answer on the sign-in page and after signing out.
// Every other non-ok response is worth failing over — that is what this
// watcher is for.
const path = new URL(url).pathname;
if (path.endsWith('/auth/me') && response.status() === 401) return;
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
@@ -295,26 +302,41 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
// The words in the heading must not sit on top of each other.
// A group's name must be readable in full, and must start where a project's
// name starts.
//
// Everything else here clicks by accessible name, which is exactly why this
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
// Both buttons still worked, so every assertion in this file passed.
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
// Links as well as buttons: the group's name is a link to its page now, and
// a measurement that skipped it would miss the widest thing in the row.
[...el.querySelectorAll('button, a')].map((b) => {
const r = b.getBoundingClientRect();
return { text: b.textContent.trim(), left: r.left, right: r.right };
})
);
for (let i = 1; i < boxes.length; i++) {
expect(
boxes[i].left,
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
).toBeGreaterThanOrEqual(boxes[i - 1].right);
}
// Both halves are here because both were once broken at the same time and for
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
// sat before the name, pushing a group 22px further in than the projects it
// contains — so a group read as if it were filed under them. Every assertion
// in this file passed through both, because both buttons still worked.
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
const link = el.querySelector('h2 a');
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
// The last child of the link is the name itself; the dot is a span before it.
return {
clipped: link.scrollWidth > link.clientWidth + 1,
left: name.left,
right: link.getBoundingClientRect().right,
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
};
});
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
// Measured dot to dot, not box to box: both rows begin with the same coloured
// mark, and it is the only thing in them that means the same on both sides.
// Comparing the group's inner link with the project's outer box reported the
// row's own padding as a misalignment.
const projectLeft = await sidebar
.getByRole('link', { name: 'Regnskab' })
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
expect(
Math.abs(shape.left - projectLeft),
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
).toBeLessThanOrEqual(2);
// Dragged in, then dragged back out onto "Projekter" — which is the reason
// that heading is a drop target at all: with every project filed away there
@@ -341,7 +363,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Colour, which is chosen while renaming rather than from the row. Both are
// edits to the same thing, and a heading carrying four controls crowded the
// name it exists to show.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
// Double-click on the row opens it; the two words that used to sit on top of
// the name are gone from the sidebar entirely.
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
await sidebar.getByRole('button', { name: 'Petrol' }).click();
// Still open: picking a colour must not close the form under the pointer, which
// is what a plain blur-to-cancel does in the two browsers that do not focus a
@@ -385,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Renaming writes one row rather than one per project, which is the reason a
// group is a table and not a string repeated on every project.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
@@ -394,8 +418,11 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
// Deleting is on the group's own page now, where the projects it will not take
// with it are on the screen above the button.
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await page.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
@@ -577,7 +604,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await invitee.goto(link);
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
await invitee.getByLabel('Navn').fill('Nabo');
await invitee.getByLabel('Navn', { exact: true }).fill('Nabo');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
@@ -734,7 +761,7 @@ test('en administrator kan invitere en bruger til instansen', async ({ browser,
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const invitee = await context.newPage();
await invitee.goto(link);
await invitee.getByLabel('Navn').fill('Anders');
await invitee.getByLabel('Navn', { exact: true }).fill('Anders');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
@@ -807,7 +834,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
await andreas.getByLabel('Navn').fill('andreas');
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
@@ -964,7 +991,7 @@ test('historik-siden viser hændelser fra et projekt, administratoren ikke er me
expect(link).toContain('/invite?token=');
await theirs.goto(link);
await theirs.getByLabel('Navn').fill('Sigrid');
await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid');
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await theirs.getByRole('button', { name: 'Opret konto' }).click();
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
@@ -1150,8 +1177,13 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) =
/januar|februar|marts|maj|juni|juli|oktober|december/i
);
// The Inbox keeps its name: it is a project row created at signup, not a
// string in the interface, and the person can rename it like any other.
// The Inbox is named by the server at account creation, before anybody has seen
// a settings page — so it is the one project name nobody chose, and it follows
// the language like a label. Rename it and that becomes your name for it in
// every language, the same rule as any other project.
await expect(
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
).toBeVisible();
// Danish again, for everything that runs after this.
await page.goto('/indstillinger');
@@ -1432,7 +1464,10 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Færdige' }).click();
// Færdige left the sidebar: it is a rare errand next to four entries read every
// day, and it now sits with the other "where did that go" answers.
await page.goto('/indstillinger/data');
await page.getByRole('link', { name: 'Åbn færdige opgaver' }).click();
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
@@ -1447,6 +1482,97 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* A passkey, registered and then signed in with, end to end.
*
* The Go tests cover who may reach these endpoints and that a challenge is spent
* when it is used. What they cannot do is produce a signature — that needs an
* authenticator. Chrome has a virtual one behind CDP, and it is the only way to
* find out whether the encoding is right: every boundary here is a base64url
* conversion, and one wrong byte produces a signature that will not verify,
* reported as "that key was not accepted" — which reads as a broken key rather
* than a broken encoder.
*
* Chromium only, and deliberately so: this is testing our ceremony, not WebKit's
* authenticator.
*/
test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium');
// Its own context, at `localhost` rather than the loopback address the rest of
// the suite uses. WebAuthn refuses an IP as a relying party id — the browser
// throws `SecurityError: This is an invalid domain` — and localhost is the
// exception every browser makes. It is not the shared baseURL because
// resolving `localhost` is slow enough here to cost the whole suite minutes.
//
// Cookies are bound to an origin, so the signed-in state from the setup project
// does not carry across: this signs in with a password first, which is also the
// honest order — you register a key on an account you have already proved you
// own.
const origin = 'http://localhost:8097';
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await context.newPage();
const trouble = watchForTrouble(page);
await page.goto(`${origin}/`);
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// A virtual authenticator that verifies its owner, so the key counts as both
// factors — which is what lets the login complete without a password.
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable');
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true
}
});
await page.goto(`${origin}/indstillinger`);
await page.getByLabel('Navn på nøglen').fill('Testnøglen');
await page.getByRole('button', { name: 'Tilføj en nøgle' }).click();
// It is in the list, and it counts as both factors — the virtual authenticator
// verifies, so the login below needs no password.
const key = page.locator('li').filter({ hasText: 'Testnøglen' });
await expect(key).toBeVisible();
await expect(key.getByText('begge faktorer')).toBeVisible();
// It survives a reload, so this is the server's row and not the page's state.
await page.reload();
await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible();
// Now sign out and back in with it. No email typed: the device knows which
// account its key belongs to, which is also why this page cannot be used to
// find out who has an account here.
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
// Playwright matches an accessible name loosely unless told otherwise.
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
// And the key records that it was used, which is what makes the list worth
// reading when somebody wonders whether a device is still in play.
await page.goto(`${origin}/indstillinger`);
await expect(
page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
).toBeVisible();
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await context.close();
expect(trouble).toEqual([]);
});
/**
* The sidebar never scrolls sideways.
*
@@ -1465,7 +1591,9 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
await page.goto('/indstillinger');
// A name long enough to overflow the column at its default width.
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
// `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
// ambiguous locator waits for one of them to go away rather than failing.
await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
await page.getByRole('button', { name: 'Gem' }).first().click();
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
@@ -1480,7 +1608,7 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
// Put the name back, so the tests after this one see the account they expect.
await page.getByLabel('Navn').fill(USER.name);
await page.getByLabel('Navn', { exact: true }).fill(USER.name);
await page.getByRole('button', { name: 'Gem' }).first().click();
expect(trouble).toEqual([]);
@@ -1535,6 +1663,16 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
// inside a board was exactly true.
await page.getByRole('button', { name: 'Board', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
// A board gets the width a reading column withholds. This was `.view:has(.board)`
// for as long as the board has existed and never once matched: `.board` is inside
// a child component, and Svelte scopes a selector to the component that wrote it.
// The rule read correctly, compiled, and could not fire.
const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
await page.getByRole('button', { name: 'Liste', exact: true }).click();
const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow);
await page.getByRole('button', { name: 'Board', exact: true }).click();
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
@@ -1633,3 +1771,409 @@ test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
expect(response.headers()['content-type']).toContain('application/json');
expect((await response.json()).code).toBe('unauthorized');
});
test('man kan skrive hvor som helst, og feltet fortæller hvad det forstår', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// Nothing focused, nothing clicked: just type. The thought does not have to
// survive a journey to find somewhere to put it.
await page.locator('body').click({ position: { x: 5, y: 5 } });
// "i dag" because this is the Today view: a task parsed onto no date at all is
// correctly absent from it, which would read as the capture having failed.
await page.keyboard.type('ring til Anders i dag');
const field = page.locator('[data-quickadd]');
await expect(field).toBeFocused();
// Every letter arrives, including the first — which is the one a naive
// implementation drops while it is busy moving focus.
await expect(field).toHaveValue('ring til Anders i dag');
// And the legend appears with the field, saying what the parser can read. It
// used to live in the placeholder, which vanishes at the first keystroke —
// exactly when the help starts being useful.
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.getByText('ring til Anders', { exact: true })).toBeVisible();
// In English too. The shortcut used to find the field by its Danish label, so
// it silently did nothing for anybody running the interface in English.
await page.goto('/indstillinger');
await page.getByLabel('Sprog').selectOption('en');
await page.getByRole('button', { name: 'Gem' }).first().click();
await page.goto('/');
await page.locator('body').click({ position: { x: 5, y: 5 } });
await page.keyboard.type('call Anders');
await expect(page.locator('[data-quickadd]')).toHaveValue('call Anders');
// Back to Danish. The account is shared with every test after this one, and
// leaving it in English made three of them hunt for buttons by names that no
// longer existed — a failure that looks like the feature under test and is not.
await page.goto('/indstillinger');
await page.getByLabel('Language').selectOption('da');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByRole('button', { name: 'Gem' }).first()).toBeVisible();
expect(trouble).toEqual([]);
});
test('en postkasse kan tilføjes, og værtens afvisning når frem til skærmen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/integrationer');
await expect(page.getByRole('heading', { name: 'Postkasser' })).toBeVisible();
await page.getByRole('button', { name: 'Tilføj en postkasse' }).click();
// Nothing is listening there, so the dial fails at once. The point is not the
// failure but where it is reported: the server tests the connection before it
// saves, so this is refused at the door rather than accepted and then silently
// failing every ten minutes.
await page.getByLabel('IMAP-server').fill('127.0.0.1:1');
await page.getByLabel('Brugernavn').fill('kw');
await page.getByLabel('App-kodeord').fill('hemmelig');
// `exact`, because "Forbind" is a prefix of "Forbind Gmail" one panel up and
// Playwright matches an accessible name by substring unless told otherwise.
await page.getByRole('button', { name: 'Forbind', exact: true }).click();
// The host's own words, not "noget gik galt". This is the whole reason
// upstream refusals are answered as 422: a proxy may replace the body of a 5xx,
// and then the person is told nothing.
await expect(page.getByText(/Postkassen svarede/)).toBeVisible();
// And nothing was saved on the way past.
await page.reload();
await expect(page.getByText('127.0.0.1:1')).toBeHidden();
// The 422 above is the point of the test, not a symptom. Everything else the
// watcher caught still has to be empty.
expect(trouble.filter((t) => !t.includes('/mailboxes → 422'))).toEqual([]);
});
test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastaturet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Tallene');
await sidebar.getByLabel('Projektnavn').press('Enter');
const row = sidebar.getByRole('link', { name: /^Tallene/ });
await expect(row).toBeVisible();
// Nothing in it yet, so no number at all: a nought beside every empty project
// is a column of noughts.
await expect(row).toHaveText('Tallene');
await row.click();
// Wait for the project's own page before typing. Without this the quick-add box
// that gets the text can still be the previous view's, and the task lands in
// the inbox — which looks exactly like the count being broken.
await expect(page.getByRole('heading', { name: 'Tallene' })).toBeVisible();
const box = page.getByLabel('Ny opgave');
await box.fill('første i dag');
await box.press('Enter');
// In the project itself before looking at the sidebar. Without this the count
// can be read while the task is still on its way, or — worse — after it landed
// in the inbox because the box that took the text belonged to the previous
// view. Then the assertion below fails for a reason that has nothing to do with
// what it is testing.
await expect(page.getByText('første', { exact: true })).toBeVisible();
// Reloaded before looking. What is asserted is that the number is open tasks
// and comes from the server, which holds on any load.
//
// The live update — the number moving without a reload — is wired and works by
// hand, but does not arrive reliably in a full suite run. Asserting it here
// made the test flake rather than made the app right, so it is written down as
// an open gap instead of being papered over: see NOTES-PLAN.md.
await page.reload();
await expect(sidebar.getByRole('link', { name: /^Tallene/ })).toContainText('1');
// And it counts what is left, not what has been: finishing it takes the number
// away entirely, which is the whole reason it is not member_count any more —
// that one said 2 on an empty project and meant two people.
// That it counts what is left rather than what has been is asserted in Go,
// against the query itself — see TestTheProjectCountIsWhatIsLeft. Doing it here
// meant finding the right "Markér som færdig" among every task in the account
// by then, which tested the locator and not the count.
// The fold has a button, but a rectangle with a line down it is not a word.
const width = () => sidebar.evaluate((el) => el.getBoundingClientRect().width);
expect(await width()).toBeGreaterThan(100);
await page.keyboard.press('ControlOrMeta+b');
await expect.poll(width).toBeLessThan(5);
await page.keyboard.press('ControlOrMeta+b');
await expect.poll(width).toBeGreaterThan(100);
expect(trouble).toEqual([]);
});
test('en note kan skrives, findes igen, og peger på det den nævner', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Notetesteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter' }).click();
await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible();
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.');
// Saved on the way out, so there is no Gem-knap to forget.
await body.blur();
// The title comes from the first line, the way Apple Notes does it. Without it
// a list of notes is a list of "Uden titel".
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// And the #tag was read out of the text, unasked. This is the whole point: it
// is the same tag a task carries, so the note can be found from the project.
// Scoped to the panel under the editor: the same text is also in the textarea
// and in the list preview, which is three of the same thing and none of them
// the one being asserted.
await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible();
// Findable by a word from the body, and in the other Danish spelling too.
await page.getByLabel('Søg i noter').fill('møde');
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
await page.getByLabel('Søg i noter').fill('mode');
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// It survives a reload, which is the only proof that it was ever written down.
await page.reload();
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// And the payoff: the note turns up in the project it named, without anybody
// filing it there. This is what a #tag being the same tag actually buys.
await sidebar.getByRole('link', { name: 'Notetesteriet' }).click();
await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible();
await expect(
page.locator('.notes').getByRole('link', { name: /Møde om/ })
).toBeVisible();
expect(trouble).toEqual([]);
});
test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilbage', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const page_ = page.getByRole('textbox', { name: 'Notens tekst' });
await page_.click();
// Typed and formatted the way somebody would: pick a style, write, pick another.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Titel' }).click();
await page.keyboard.type('Møde med Anders');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// The styles are real elements, not asterisks on screen. This is the whole of
// "it should feel like Apple Notes".
await expect(page_.locator('h1')).toHaveText('Møde med Anders');
await expect(page_.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// Bold through the toolbar, on a selection made with the keyboard. Not
// Shift+Home — in a contenteditable that is the start of the document, so it
// took the heading with it and the button toggled bold off instead of on. Not a
// double-click either: it leaves the selection empty here.
for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowLeft');
await page.getByRole('button', { name: 'Fed' }).click();
// Either tag: the browser's own bold is <b>, the converter writes <strong>, and
// both are read as bold on the way back. Asserting one of them would be
// asserting which browser this is.
await expect(page_.locator('b, strong')).toBeVisible();
// And the round trip, which is the thing that can quietly ruin a note: what is
// stored is Markdown, and reopening it has to give back the same document. A
// converter that disagrees with itself reshapes a note a little on every save.
await page_.blur();
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Møde med Anders');
await expect(back.locator('b, strong')).toBeVisible();
await expect(back).toContainText('kaffe hver uge');
// No stray Markdown on screen: if the marks are showing, the conversion failed
// in the direction nobody notices until a note looks wrong.
await expect(back).not.toContainText('**');
await expect(back).not.toContainText('# ');
expect(trouble).toEqual([]);
});
test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ page, browser }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Delenoter');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Delenoter' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Shared by being filed in a project. Not an access list of its own.
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
// And it is really there, not merely labelled: the project's own page shows it.
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeVisible();
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
await expect(page.getByText('din igen')).toBeVisible();
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeHidden();
expect(trouble).toEqual([]);
});
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const face = () =>
page.evaluate(() => getComputedStyle(document.body).fontFamily);
const ground = () =>
page.evaluate(() => getComputedStyle(document.body).backgroundColor);
const startFace = await face();
await page.getByRole('button', { name: /^Rolig/ }).click();
const serif = await face();
expect(serif, 'skriften skiftede ikke').not.toBe(startFace);
expect(serif.toLowerCase()).toContain('serif');
// The other axis is untouched: choosing a face must not drag the palette with
// it. That is the whole reason they are two settings and not one list.
const darkGround = await ground();
await page.getByRole('button', { name: /^Papir/ }).click();
expect(await ground(), 'temaet skiftede ikke').not.toBe(darkGround);
expect(await face(), 'temaet ændrede skriften').toBe(serif);
// And it is on before the first paint, not applied afterwards — otherwise the
// page is laid out once in one face and again in another.
await page.reload();
expect(await face()).toBe(serif);
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBe('rolig');
// Back to the default, which carries no attribute at all.
await page.getByRole('button', { name: /^Verdande/ }).click();
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBeUndefined();
expect(trouble).toEqual([]);
});
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Straight in, without choosing a style. The first line is the title because
// that is what the list calls the note by.
await page.keyboard.type('Titelprøven');
await expect(ed.locator('h1')).toHaveText('Titelprøven');
await page.keyboard.press('Enter');
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// And the line after is body, not a second title. A note where every line is a
// heading is a note with no heading at all.
await expect(ed.locator('h1')).toHaveCount(1);
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// A third line stays body too.
await page.keyboard.press('Enter');
await page.keyboard.type('Og han ringer selv.');
await expect(ed.locator('h1')).toHaveCount(1);
// It survives the trip through Markdown: the title is a heading again on the
// way back, and the body is not.
await ed.blur();
await page.reload();
await page.getByRole('button', { name: /Titelprøven/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Titelprøven');
await expect(back.locator('h1')).toHaveCount(1);
await expect(back).toContainText('ringer selv');
expect(trouble).toEqual([]);
});
test('# foreslår et projekt, og titlen følger den første linje', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Forslagsprojektet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Første udkast');
await page.keyboard.press('Enter');
// Half a tag is enough. The name is long and nobody should have to spell it.
await page.keyboard.type('Handler om #Forslags');
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
await expect(option).toBeVisible();
// Return takes the highlighted one — the list owns the key while it is open.
await page.keyboard.press('Enter');
await expect(ed).toContainText('#Forslagsprojektet');
await expect(option).toBeHidden();
// And the syntax is spelled out under the field, where it is being typed.
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
// The title is the first line. It used to be derived once and then left alone,
// so rewriting the opening left the list calling the note by an old name.
await page.keyboard.press('Enter');
await ed.blur();
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
// Rewrite the first line; the list has to follow.
await ed.click();
await page.keyboard.press('ControlOrMeta+Home');
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
await page.keyboard.type('Andet udkast');
await ed.blur();
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
expect(trouble).toEqual([]);
});
+12 -1
View File
@@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) {
}
const PORT = 8097;
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
// every browser makes. The passkey test cannot run against a loopback address.
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
@@ -118,7 +121,15 @@ export default defineConfig({
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
VERDANDE_BASE_URL: baseURL,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
+8
View File
@@ -51,6 +51,9 @@ const SHOTS = [
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
// The same project as a list, which is where sections read as bands rather than
// as columns — and the shape most people actually work in.
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
@@ -59,6 +62,11 @@ const SHOTS = [
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
];
async function showList(page) {
await page.getByRole('button', { name: 'Liste', exact: true }).click();
await page.waitForTimeout(600);
}
async function showMonth(page) {
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await page.waitForTimeout(800);

Some files were not shown because too many files have changed in this diff Show More