Compare commits

...
96 Commits
Author SHA1 Message Date
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-08-18 11:56:54 +02:00
Kristian WindandClaude Opus 5 982c3aab18 test(i18n): commit the guard that was written and never added
Release / Build and publish image (push) Failing after 10m6s
It was created alongside the three untranslated strings it catches and left
untracked — `git add -u` stages tracked files only. CI has been running without
it since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:19:26 +02:00
Kristian WindandClaude Opus 5 a52ad9817f feat(tasks): undo a completion, and a place to find what was closed
Reported from use: "I just closed a task by accident, and there is no undo and no
list of closed tasks." Both halves were true.

Completing is one click on a small circle beside a row you were only reading, and
it takes the row off the screen — so the mistake and the evidence of it leave
together. There was no name left to search for and nowhere to look: the project
page can show its own closed tasks, but only if you already know which project it
was in.

The toast covers the mistake you notice at once. It names the task, so you can see
which one you hit, and stands for twelve seconds rather than five — five is enough
to read "could not save" and not enough to notice, decide and reach the mouse. A
toast with an action also gets two targets: the message dismisses and the action
acts, which were one button before and would otherwise put "yes, put it back" and
"go away" in the same place.

`/faerdige` covers the one you notice tomorrow. A place rather than a toggle in
every view, because somebody hunting for a task they closed does not know which
view they closed it in. Newest first — a record of what is finished is read
backwards from now, not by a due date the task no longer has — and grouped by the
day, because "I dag" and "I går" is how anybody would describe it.

A recurring task is offered no undo. Completing it moves it to its next date rather
than closing it, so there is nothing to reverse, and an undo that quietly rolled
the date back would be a different act from the one it claims to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:18:01 +02:00
Kristian WindandClaude Opus 5 0dbf7a40bf feat(tasks): write a task straight into a section
The box at the top puts things in the project with no section, which is right for
capturing. But a project has sections because the work belongs somewhere, and
dragging every new task down into the right one is a second gesture for something
you already knew when you typed it.

Two ways in, because they answer different moments. A field at the foot of each
section and each board column — standing there is enough, no syntax to know. And
`/section` in quick add, for the box at the top and for Today and Upcoming, where
you are not standing in a section at all.

The field goes through quick add rather than createTask, so a line written there
still parses its dates, priorities and labels.

The sigil needed care: '#' and '@' never turn up in a Danish task line by accident
and '/' turns up constantly. It has to follow whitespace and be followed
immediately by the name, which "km/t", "3/4" and "20 kr / stk" all fail. The bare
form is greedy, because "Kunder/Ordrer" is a real section name and the sigil is
inside it.

A section id from the client is checked against the project the task actually
landed in. A `#project` in the same line can move the task elsewhere, and the id
would otherwise follow it into a project that section does not belong to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:13:10 +02:00
Kristian WindandClaude Opus 5 9cc7df69f0 fix(projects): the header stops crushing the project's name
"GarageRisteriet" rendered as "GarageRist / eriet" — broken mid-word. A name split
mid-syllable does not read as a wrap, it reads as a rendering fault.

Two causes. The row carried eight controls beside the heading — colour, show-done,
three view buttons, share, history, delete — and the heading was the only flexible
thing in it, so it was what gave way. And `overflow-wrap: anywhere` breaks at the
first opportunity rather than the last necessary one, so what gave way, gave way
inside a word.

Everything but the view switcher is behind one button now. What stays out is what
you use while working — which view you are in. What moves in is what you do *to* a
project rather than in it, and most of it once ever. The heading gets the row back,
and `break-word` only splits a word that could not fit on a line of its own.

The menu closes on Escape and on a click anywhere else, listened for in the capture
phase so a click whose own handler stops propagation still closes it. A menu you
can only leave by pressing its own button again is one you click twice to get out
of.

Three more untranslated strings turned up on the way: "Fjern {member.name}", "Luk
menu" and "En slettet konto". The guard missed them because none of its words
covered them — it knows fjern, opret, vælg, luk, tilføj, ryd, gem, slet and omdøb
now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:00:32 +02:00
Kristian WindandClaude Opus 5 ab5e3e35a6 fix(i18n): three Danish strings that had no æ, ø or å in them
Spotted on the running instance: the version line read "Running v0.11.0. Der
bliver ikke set efter opdateringer." — half translated, on one line.

The sweep that was supposed to catch these grepped for Danish letters, and none of
the three survivors contain one: "Ingen nyere version fundet.", "Ikke mere i dag."
and a token hint split across two source lines so neither half looked like prose.

So there is a test for it now. It reads the templates and looks for the words that
give Danish away whatever letters they are spelled with — a word list rather than a
language detector, because this ships as one static binary and thirty common words
catch the sentences a person actually writes. It fails on the exact three that got
through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:50:25 +02:00
Kristian WindandClaude Opus 5 d6d5bf590b fix(sections): a section can hold more than one task
Reported as "sections can only have one task", and that is exactly what it looked
like.

TaskList read the dragged task from its own `draggingId`, which is only set when
the drag *started* in that list. A task dragged in from the unsectioned rows above,
or from another section, arrived as null and the handler returned early — after it
had already called `stopPropagation`, so the section around it never got the drop
either.

So a section could only be dropped into while it was empty, because an empty one
has no row in the way to swallow the drop. Put one task in and there was no route
to a second. The id comes out of `dataTransfer` at drop time now, and `dragover`
decides with `carries()` rather than local state — which is the only thing a target
can read while a drag is in the air.

The stuck frame was the same cause: the section's own cleanup never ran, so the
highlight stayed lit after the drop. It is cleared on `dragend` now, which fires
once per drag on the source and bubbles to the window — the one place that is true
however a drag ends, including one abandoned with Escape, which produces no drop at
all and left the frame standing too.

The smoke test drops a second task onto the row already in the section, which is
the gesture that did nothing, and runs in WebKit as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:35:06 +02:00
Kristian WindandClaude Opus 5 6853897d00 ci: install WebKit as well, for the one test that needs it
The `webkit-forms` project was added to the Playwright config and CI was not told,
so it installed Chromium and the one WebKit test died on a missing executable —
26 passed, 1 failed, and the failure said nothing about the app.

It is one test on purpose. The suite stays Chromium, because running it in three
engines finds the same breakage three times; focus is the exception, and Safari
ignoring `autofocus` on anything inserted after load is what made "sections have
no function" true from inside Safari.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:05:29 +02:00
Kristian WindandClaude Opus 5 726a489a67 fix(migrations): say why foreign keys go off, accurately
Release / Build and publish image (push) Failing after 10m0s
The note on 0009 and 0012 said the drop would cascade and take every row with it.
It would not: nothing references `activity` or `attachments`, so dropping either
takes nothing along. That claim was carried over from 0008, where seven tables do
reference `tasks` and it is exactly right.

The real reason is the check that comes with the marker. With foreign keys on, the
copy is validated a row at a time against a half-rebuilt schema, and the first bad
row aborts with no idea how many others are like it. With them off,
`PRAGMA foreign_key_check` runs once before the commit and names what is actually
wrong — which matters, because these run against a live database on somebody's
next restart and a failed migration is a server that does not start.

The upgrade test now carries activity rows, an attachment and a group across all
five migrations rather than stopping at 0008, and checks that the columns added by
ALTER arrived with their defaults rather than as NULL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:01:18 +02:00
Kristian WindandClaude Opus 5 626ea9aaec feat(tasks): finished work can be looked at, and is out of the way by default
A task list is what is left to do, so closing something has to take it off the
plan. But "what did I get done" is a real question and the answer was nowhere:
once a task was closed it left the interface entirely.

They come back in one list at the bottom rather than back among the open ones in
their sections. A closed task has stopped being work and started being a record,
and putting the record in the middle of the plan makes the plan longer without
making it say more.

Off by default, and deliberately: a list that opens full of things already done is
a list you have to read past before you can use it.

In localStorage rather than on the account, like the sidebar's width and unlike a
folded group. It is a way of *looking*, not a statement about the work — somebody
reviewing their week wants it on for ten minutes, and a setting that followed them
to every device is one they would have to turn off twice.

`loadTasks` asks for closed tasks when the setting is on, rather than each caller
deciding. A view added later cannot then forget and quietly ignore it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:49:58 +02:00
Kristian WindandClaude Opus 5 4d8b70918b feat(i18n): the interface speaks both languages
The account has had a `locale` since the beginning and it only chose which grammar
quick add read a line with — so picking English changed nothing anybody could see,
and the field said so honestly: "Sprog i hurtig tilføjelse". It says "Sprog" now,
because it means it.

Every component goes through `t()`. Danish is the source: a key missing from
English falls through to Danish rather than to the key, because a Danish sentence
in an English screen can be read and `settings.notifications.title` cannot.

`tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a
dozen components, and that is the half of a translation that gets left behind — an
English interface still saying "24. aug." is exactly the shape of a job half done.
There is a smoke test that switches to English and refuses to see a Danish month
name in the calendar's heading.

Colour and theme names are keys now. `colors.js` keeps its ids untouched — they
are what the database stores, and `internal/store` reads them out of that file —
but `name` is `'color.tomato'`, resolved where it is drawn.

The Inbox keeps its name. It is a project row the server creates at signup, not a
string in the interface, and it can be renamed like any other project.

Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had
already drifted: one said "aldrig logget ind", another "aldrig", and two stopped
at days where the others carried on to a date.

Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and
they are in every smoke test; translating them buys nothing and costs both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:46:08 +02:00
Kristian WindandClaude Opus 5 e3c7fc792a feat(i18n): the machinery, and the shell translated
The account has had a `locale` since the beginning, and it only chose which
grammar quick add parsed a line with — so setting it to English changed nothing
anybody could see. This is the other half of that field, started.

`t()` reads `i18n.locale`, which is `$state`, so every component that calls it
redraws when the language changes; that is what makes the setting take effect
without a reload, and why the module is `.svelte.js`. Danish is the source and the
fallback: a key missing from English falls through to Danish rather than to the
key, because a Danish sentence in an English screen can be read and
`settings.notifications.title` cannot. `tag()` is the single place `Intl` is
asked, so dates follow the language instead of being `da-DK` in a dozen files.

`internal/i18n` is a Go package that exists only to fail the build when the two
dictionaries drift — the same trick `colors_test.go` plays on the palette. It
checks the placeholders as well as the keys: a translation that spells `{n}` as
`{antal}` leaves `{antal}` on the screen, which is worse than an untranslated
sentence because it looks like a bug in the app rather than a gap in a dictionary.

Converted so far: the shell. Sidebar, layout, quick add, task row, command
palette — fifty-two keys, and the surfaces people look at constantly. Twenty
files remain, the settings pages being the heavy ones.

The language picker is deliberately not wired up yet. It still says "Sprog i
hurtig tilføjelse" and still only changes the parser. Wiring it before the sweep
is done would hand anybody who picks English a bilingual screen — the
half-finished-theme failure `app.css` already taught us. As it stands Danish is
unchanged and English is not offered, which is the honest state for something
half done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:25:58 +02:00
Kristian WindandClaude Opus 5 804963b222 feat(groups): a group becomes somewhere you can go
It was a heading over some rows: a name, a colour, and whether it was folded.
Enough to tidy a list, and not enough to be what people mean by it — "Arbejde" is
a body of work with context of its own, and documents that belong to all of it
rather than to any one project inside it.

`/gruppe/{id}` carries three things a heading cannot. The projects in it, each
with what is still open — open tasks, not all of them, because "12" beside a
project somebody finished last year is a number that means nothing. A description,
written in place and saved on blur, the same shape a task's description has. And
the files that govern the whole group: the contract, the drawing, the agreement
everything else rests on.

The name in the sidebar is a link to it and the chevron beside it still folds.
Two intentions, two targets — the single one they used to share could only ever
serve the smaller.

Migration 0012 rebuilds `attachments`, because its CHECK spelled out exactly which
parent columns may be set and SQLite cannot alter a CHECK. The rule is still
"exactly one parent", counted rather than written out as pairwise comparisons:
with three columns that is three clauses, with four it is six, and the version
that is wrong looks exactly like the one that is right.

A group's file is reachable by its owner and nobody else. A group belongs to one
person and is never shared, so `reachAttachment` asks about ownership rather than
a project role — both old callers resolved straight to a task, so a group's
attachment answered 404 for the person who uploaded it.

Three tests also stopped racing their own navigation. Creating a project navigates
and `goto` is a promise nobody was holding, so a quick add fired straight after
could land in the Inbox instead — intermittent, and it read as the quick add
having failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:20:03 +02:00
Kristian WindandClaude Opus 5 0b467beb3e docs: the API and the handover, brought up to what is here
The OpenAPI document gains the audit log, the backups, the Gmail client and the
sidebar sections, and loses the paragraph that said deleting an account takes
every task the person ever wrote.

The handover gains the day: why an account can be deleted without taking other
people's work, why the audit log pages with a keyset, why a week view exists at
all, why form fields are 16px on touch, and why `autofocus` is gone.

And the trap that cost the most, which was not in the code: the build hung on 0%
CPU because iCloud had evicted node_modules and a stalled download blocks a read
forever. The fix is a repo path outside a synced folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 1a6adfd9d3 docs: screenshots that regenerate themselves, and eleven of them
They were many rounds behind — no settings, no task detail, no themes, no groups,
no colours, no calendar, no admin pages. The reason is in how they were made:
`tools/shots` needs a running instance and a session cookie pasted in by hand, so
it ran about as often as somebody felt like doing that.

`node scripts/generate-screenshots.js` starts the real binary, creates its own
account, fills it with Danish demo data and photographs eleven surfaces into both
`site/screenshots` and `docs/screenshots`. Nothing to set up and nothing to
remember.

A script rather than a test, for the same reason `generate-icons.js` is one: CI
must never rewrite a tracked binary file.

`tools/shots` stays. It is still the way to photograph an instance that is
already running rather than a fresh one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 01d498be20 fix(projects): group_id was accepted on create and thrown away
The field was declared on the request type, commented on it, and applied by the
update handler — and the create handler never read it. Creating a project into a
group answered 201 and put it nowhere. A field that is accepted and discarded is
worse than one that is refused: nothing reports it.

Found by looking at a screenshot. Both groups stood empty while the projects sat
loose above them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 31bf0557df feat(gmail): the OAuth client can be entered instead of deployed
The one click that opens Google's consent screen already existed. What it needs
behind it is a registered client, and no app can conjure one: Google issues no
Gmail access to an unregistered client, and `gmail.readonly` is a restricted
scope, so an id shipped inside a public image would be both extractable from it
and unusable without Google's security review of every deployment using it.

The registration is a one-off. Having to edit a Rune's manifest and recreate the
container to paste the result was not.

`VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when set. A value in the
environment is a deliberate deployment decision, applied again on every recreate,
and a form that could quietly override it would make the manifest a lie — so the
page says where the value came from, and PUT answers 409.

The redirect URI is spelled out on the page rather than left to be reconstructed.
It is derived from VERDANDE_BASE_URL, it has to match Google's copy exactly, and
the error Google returns when it does not names neither value.

Its own table rather than `user_settings`: an OAuth client is the instance's, not
one person's — and `user_settings` has `user_id` as its whole primary key, so a
second scope there would silently overwrite the AI provider's key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:05:18 +02:00
Kristian WindandClaude Opus 5 101ca33069 feat(tasks): a row says what is underneath it, and what is attached
A task with three sub-tasks looked exactly like one without, and so did a task
with a drawing on it. The whole reason to attach something is that somebody
should find it.

Counted in the same query as the tasks, as correlated subqueries: a list of fifty
would otherwise be a hundred more lookups, and the point is that you see this
without asking.

Said as a fraction rather than a total. "3" tells you something is there; "1/3"
tells you whether there is anything left to do, which is the question a list is
for. All-done goes accent-coloured, because "3/3" and "1/3" are the two answers
you actually look for and only one of them is good news.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:05:18 +02:00
Kristian WindandClaude Opus 5 eaff890860 fix(forms): Safari focused none of them, and a board could not make a section
Two reports, one sentence between them: "sections have no function, because you
cannot create more than one."

`autofocus` does not work in Safari for anything inserted after the page has
loaded, and every inline form here is inserted after the page has loaded — rename
a project, add a section, name a group. In Safari they all opened with no cursor
in them and what you typed went nowhere. That does not look like a bug. It looks
like the feature does not work. `use:focusOnMount` replaces all seventeen.

And the board had no way to add a section at all. The button lived only in the
list view, which is the one place nobody looks for it: a column on a board *is* a
section, so that is where you go to make one.

Chasing this turned up a third. The task drawer's three fields save on blur, and
closing the drawer *is* a blur — the input is being unmounted and the browser
fires blur on the way out. If the task left the store in the same moment, `task`
was already null and the handler threw `Cannot read properties of null`, stopping
the page. It only became reproducible once focus actually worked.

Fields are 16px on a touch device now, whatever the component asked for. Below
16px, iOS Safari zooms in when a field takes focus and does not zoom back out —
so the whole interface is left too large and has to be pinched back by hand, once
per field. The type scale tops out at 15px, so every input in the app did it.

The sidebar's resize handle sat at `right: -3px`. An absolutely positioned child
counts towards its container's scrollable overflow, and `overflow-y: auto`
resolves the other axis to `auto` too — so those three pixels gave the whole menu
a horizontal scrollbar on every desktop, always. The account name could do it as
well: `overflow: hidden` with no `min-width: 0` cannot shrink, so the row grew
instead of ellipsing.

There is a WebKit pass in the suite now, over one test. The rest stays Chromium
on purpose — a smoke test exists to catch a build that does not work, and three
engines find the same bug three times — but focus is the exception, and it took a
person using the app to find that out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:05:02 +02:00
Kristian WindandClaude Opus 5 1da7ea64bf feat(admin): the nightly backup, made visible
It has run since the beginning and nothing showed it. A backup nobody can see is
a backup nobody knows has been failing, and the failure is quiet: a volume gone
read-only, a disk that filled, a container that has not been up at midnight all
week. The first anybody hears of it is the day they need one.

`ListBackupRuns` was already in the store and exposed nowhere. Now there is a
list, a download, and a button that takes one now — the same work the nightly job
does without its "one a day" check, because waiting until tonight to learn whether
backups work at all is how somebody finds out on the day they need one, and it is
the only way to get a snapshot from *before* something you are about to do.

Restoring is deliberately not a button. Swapping a database under a server that
is writing to it means stopping every writer, replacing the file and reopening,
and a half-done version of that destroys the thing it was restoring. The panel
says what to do instead.

`RunBackupNow` turns out to have been a second copy of the backup logic, written
for a button that did not exist yet, and the two had already drifted: it never
created the backups directory and logged nothing. One implementation now.

Administrators only, and sessions only. A backup is a complete copy of the
database, so this hands out everybody's data in one file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:04:43 +02:00
Kristian WindandClaude Opus 5 04a7ba1024 feat(sidebar): headings that fold, a group head that is not indented, and colour where it is edited
Three things reported from use, all in the sidebar.

Projekter, Delt med mig, Filtre and Etiketter fold, the way a project group
already does. Kept on the account rather than in localStorage, and for the same
reason the group's own `collapsed` is: folding a heading says "this is the part
of my work I am not in right now", and that is as true on the laptop as on the
desktop. The sidebar's *width* is the opposite case and stays per browser. A JSON
array of keys rather than a column each, so a renamed heading is not a migration.

The group heading is no longer indented — only what is filed under it. The colour
dot sat as a sibling before the fold button and pushed the whole row in, and a
heading that starts further in than the rows above it says the heading is inside
something.

And the colour is chosen while renaming. The dot was itself the button, which
gave the heading four controls on a line narrow enough to crowd the name; both
are edits to the same thing, so they belong in the same moment. The form closes
on `focusout` and only when focus has left it entirely, and the swatches refuse
focus on mousedown — Safari and Firefox do not focus a button on click, so
`relatedTarget` would be null and the form would shut under the pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:04:30 +02:00
Kristian WindandClaude Opus 5 b169591209 feat(upcoming): a week at a time, so a drag can cross a month boundary
A month grid is anchored to a month, so the two days on either side of its edge
sit in different grids — and you cannot page mid-drag, because a drag in flight
swallows the click that would page. A week that straddles the 31st and the 1st
has both days in the same row.

The same component as the month, with `span="week"`, rather than a second one:
the part that is easy to get almost right is the same in both. A drop target has
to decide from `dataTransfer.types` alone whether it accepts, and two copies of
that are two places to be almost right.

Seven cells instead of forty-two, so the height a month spends on six rows goes
into one and the cells hold a real list rather than a hint. On a phone the row
stacks and each day carries its own weekday name, because a column of bare
numbers is not a week.

`calendar` still means the month in localStorage. It is the value already stored
for everybody who chose it, and renaming it would silently reset them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:04:30 +02:00
Kristian WindandClaude Opus 5 85f7b2488d feat(admin): what has been done on this server, across every project
The other half of the error log. That one is what broke; this is what was done,
and nobody could ask it: `ListActivity` takes one project id, and an
administrator is not necessarily a member of the project they need to look into.

Paged with a keyset cursor rather than an offset. The log is written to
constantly and read rarely, so a page fetched a minute after the one before it
would be shifted by everything written in between — one row shown twice and the
row behind it skipped, which is the single failure an audit log must not have.
Both halves of the cursor matter: the log is written in whole seconds, so a
timestamp alone cannot separate two rows written in the same one.

Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it
to `created_by`. A log whose rows vanish with the account they describe answers
"what has happened here" with everything except the part somebody had a reason to
remove. `project_id` keeps cascading on purpose: the trash is the undo, and past
it the deletion was deliberate.

The event names moved to `web/src/lib/events.js`, because two surfaces read the
same log now and two copies of nineteen strings is two places for them to drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:04:12 +02:00
Kristian WindandClaude Opus 5 ebdc8eacf2 feat(admin): deleting an account stops deleting other people's work
`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every
task they had ever written — including in projects somebody else owns. The
interface said so in plain words before you pressed the button, which is a
warning rather than a fix. It was the last remaining way to lose other people's
work by accident.

SET NULL instead: the task stays and loses its author, the same shape
`assignee_id` and `completed_by` already have.

SQLite cannot alter a foreign key action, so the table is rebuilt — and with
foreign keys on, the drop in the middle of a rebuild is a cascading delete that
takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op
inside a transaction, so migrations now run on a connection of their own, and a
migration can opt out of enforcement with `-- verdande:rebuild-tables`.
`PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
dangling reference fails the migration rather than writing it.

The test fills a database at 0007, upgrades it, and counts. Without the marker it
loses a task, a label link and a comment — which is exactly what it is for.

The admin list now says two numbers instead of one: what disappears, and what
stays behind unattributed. They are different sentences and the second is a
reassurance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:03:59 +02:00
Kristian WindandClaude Opus 5 a29534c811 build: lift Vite to 8, and get the tree out of iCloud's way
The frontend build hung on 0% CPU, roughly every other run: blocked in read()
inside libuv, no output, forever. `svelte-kit sync` alone did it too, so it was
not the bundler.

The cause was not the toolchain. `~/Documents` is iCloud-managed, and iCloud
evicts what it thinks is cold — which for a dependency tree is nearly all of it:
359 of 400 sampled files under node_modules were `dataless`. Reading an evicted
file blocks until it is downloaded again, and a download that stalls hangs the
build for good. The tell was a different error entirely: Playwright once reported
`ETIMEDOUT: connection timed out, read` while loading jsconfig.json, and
ETIMEDOUT on a local file read does not exist.

Vite 5 and esbuild 0.21 predate Node 22 and this machine runs Node 26, so they
went up anyway — to Vite 8 with @sveltejs/vite-plugin-svelte 7, which SvelteKit
2.70 takes. `package-lock.json` had to be deleted for npm to resolve it; it held
plugin 4 and rejected the whole tree with ERESOLVE. That made the build faster.
It was not what was hanging.

`engines` says Node >= 22.12 now, so the next person is told rather than left
watching a process do nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:03:45 +02:00
Kristian WindandClaude Opus 5 494640c1a8 fix(mobile): the drawer no longer pushes the page sideways
Release / Build and publish image (push) Failing after 10m8s
The task drawer was `width: 100vw`. A viewport unit includes the scrollbar
gutter and a percentage does not, so the drawer came out a few pixels wider
than the page it was pinned to and the whole document could be shoved
sideways. It is `100%` now, and at phone width it is stated as four insets
instead — a box defined by the viewport's own edges cannot be a rounding
error wider than it.

The scrolling body gets `min-width: 0` and `overflow-x: hidden` too, so a
pasted URL or a long unbroken word in a comment cannot stretch the flex
column and take the page with it.

Writing the test for that found another one: creating a project from the
sidebar navigated to it and left the drawer covering the screen. Every link
in there closes it on the way out; the form did not, because it calls
`goto` directly. Now it does the same thing for the same reason.

The phone test checks the document cannot be scrolled horizontally, both
with the drawer shut and with it open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:24:49 +02:00
Kristian WindandClaude Opus 5 6d27d730d2 fix(mobile): the project header wraps instead of crushing the title
Release / Build and publish image (push) Failing after 10m4s
On a phone, a project called "Skovvænget" rendered as a vertical column of
single letters while the buttons ran off the right edge. It looked like a
broken font.

The header is a flex row of a title and four controls, and 390px does not
have room for them. Nothing wrapped, so the heading was shrunk to its
min-content — which with `overflow-wrap: anywhere` is one character. Adding
Historik this evening is what tipped it over.

It wraps now, and the title keeps a floor of 8ch so flex stops squeezing at
the point where wrapping is the better answer. Kommende had the same shape
and gets the same fix.

Every other smoke test runs at desktop width, which is exactly why this
shipped. There is one at 390 now: it measures the heading's box — wide
enough to hold a word, short enough not to be a column of letters — and
checks the page cannot be scrolled sideways.

Also stops an empty project saying both "Uden sektion" and "Ingenting her
endnu": that label is a heading for the tasks outside a section, and with no
sections there is nothing for them to be outside of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:09:08 +02:00
Kristian WindandClaude Opus 5 b0ab6ee5c0 fix(docker): build the frontend and the binary once, and cross-compile
Two of the last five releases died at exit 132 — SIGILL, an instruction
QEMU does not implement — partway through `npm run build`. It reads as a
broken build and is a broken emulator.

The cause is that neither builder stage was pinned to the build platform,
so buildx ran both once per target: Node and esbuild under emulation for
arm64, and the entire Go toolchain too. Nothing in either stage needs to be
the target architecture. The frontend produces HTML, CSS and JavaScript,
which are the same bytes wherever the image runs, and Go cross-compiles
natively with CGO already off.

So both are `--platform=$BUILDPLATFORM` now, and the binary is built with
`GOARCH=${TARGETARCH}`. The frontend is built once instead of twice, and
nothing is emulated at all — which should also take most of the fifteen
minutes off a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:04:09 +02:00
Kristian WindandClaude Opus 5 c2e2d66e76 feat(admin): keep server errors where a restart cannot take them
Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at
11:49, twice from another at 10:45 — and there is no way to find out what
any of them were. verdande logs the real cause of every 500, but on a Rune
the log belongs to the container and the container is replaced on every
restart. So an operator is told that something broke and given nothing to
act on, which is the worst of both: alarming and useless.

Every 500 now writes a row: what the handler was attempting in its own
words, the error it hit, the method and path, who was asking, and the
request id that ties it back to the log line for whoever still has it. The
"what" is the part a status code cannot give — "list projects" and "move
task" are the difference between a fault you can find and one you can only
count.

Written best-effort and never checked. This is the path that is already
reporting a failure, and a diagnostic that can itself fail the request
would turn one broken screen into two.

Threading the request through `internal()` and `storeError()` touched 108
call sites across 19 files, which is the price of not having had it there
from the start. It is mechanical and the compiler checked all of it.

Kept for thirty days by the nightly sweep: long enough to answer "what
happened last week", short enough that a fault loop cannot fill the volume
the tasks live on.

Read at Indstillinger → Fejl, administrators only — it carries paths,
account names and the server's own error strings. The empty state says
"Ingen fejl registreret. Det er den rigtige tilstand", because a blank
panel should not read as a broken one.

The test drops a table and asks for the endpoint that reads it: the same
code path a disk error takes, and the row that comes back has to name the
operation, the account and the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:58:02 +02:00
Kristian WindandClaude Opus 5 4be934ccc1 feat(pwa): the PNG icons the manifest and iOS have always wanted
The manifest pointed at icon.svg and nothing else. Chrome and Firefox
install happily on that; Safari does not read the manifest's icons at all
and looks for /apple-touch-icon.png by name, so an installed PWA on iOS got
a screenshot of the page as its home-screen icon.

This was written down as blocked: there is no SVG renderer on this machine,
which is why the references were removed rather than left pointing at files
that did not exist. Playwright is an SVG renderer, and it has been a
dependency the whole time.

scripts/generate-icons.js renders the same SVG at 192, 512 and 180. A script
rather than a test, so CI never runs it and never rewrites a tracked binary
— run it when the icon changes. The smoke test already fetches every icon
the manifest names, and now asks for the Apple one by name too, so a
reference with no file behind it fails there rather than on somebody's
phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:37:52 +02:00
Kristian WindandClaude Opus 5 7094e05ddd feat(sidebar): drop a task on "I dag"
"Do it today" is the most-made rescheduling there is, and the sidebar is
where the pointer already is when you have picked a task up.

Only that one of the two date views. "Kommende" is a range rather than a
day — it starts today and runs a week — so a drop on it would have to
invent a date its own label does not name. The month grid inside it takes a
drop per cell, which is where a specific day is actually on offer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:35:40 +02:00
Kristian WindandClaude Opus 5 35d9e3bdac feat: an assignee on the row, and the activity log made visible
Release / Build and publish image (push) Failing after 10m2s
Two more things the database has been recording and the interface has
never shown.

**Who a task is waiting on, on the row.** Only when it is not you: your own
list would otherwise carry your own face on every line, which is a column
of one initial telling you nothing. The point of showing an assignee is
that it is somebody else.

That needed names, and a task carries only an `assignee_id`. `GET /people`
is the primitive: everybody you share a project with, plus yourself,
fetched once per session. Not per row, and not per project either — a list
would have to know which project each task belongs to before it could ask
for that project's members. Deliberately *not* the instance's user list,
which is `/users` and administrators only: a task list has no business
enumerating everybody with an account here.

It sits first in the meta line, because who changes what the rest of the
line means — a date on somebody else's task is their deadline, not yours.

**And the activity log.** Nineteen kinds of entry have been written since
the beginning — every project, section, member, task and comment event —
and `GET /projects/{id}/activity` has answered honestly the whole time with
nothing on the other end. There is a Historik panel on the project now,
loaded when it is opened rather than with the page, and the event keys
become Danish in exactly one place. An unknown key falls through to itself
rather than being hidden: a build that records something this table has not
learned should still show that it happened.

One test assertion had to change, and it is worth saying why. It matched
"males inden fredag" against a row whose title is "males inden" — quick add
read "fredag" as the due date — so it was really matching the title
concatenated with the metadata beside it. Putting the assignee between them
broke it. The assertion now names the task's own text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:31:43 +02:00
Kristian WindandClaude Opus 5 b6ea87ac19 feat(projects): change a member's role from the member list
An invite sent with the wrong role could not be corrected. The only way
back was to remove the person and invite them again — and removing a member
unassigns every task they were responsible for, so fixing a dropdown cost
somebody their work. This is the other side of "an external user cannot
create tasks": the rule was right, the role was wrong, and there was no way
to say so.

PATCH against a membership that already exists, not the upsert AddMember
does. Somebody who is not a member is not silently made one: that is an
invite, it belongs to the invite flow, and doing it here would let a typo
in a user id add a stranger to a project without anybody being asked.

The owner is refused, with the real reason rather than the true but useless
one. Ownership lives on the project rather than in project_members, so the
plain answer would have been "not a member" — which is correct and tells
nobody anything. Ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom.

In the list, the owner's standing is text and everybody else's is a
dropdown. A disabled control would be a thing that exists to say no.

And the roles are finally in Danish. The API's words are `owner`, `editor`
and `viewer`, and they were being printed straight into a Danish page; the
list now uses the same three phrases the invite form beside it already did.

The smoke test drives both browsers, because the interesting part is that
the other one's permissions really change: a viewer is told the project is
read-only and has no quick-add box, the owner promotes them from the list,
and after a reload the box is there and the notice is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:14:33 +02:00
Kristian WindandClaude Opus 5 fc32fea607 fix(projects): a section can be dropped into, and a failed load says so
Release / Build and publish image (push) Failing after 9m53s
Two things from the same report — an external member who could see a
project but could not get work into it.

**A section with no tasks could not be filled by dragging.** Only the task
rows accepted a drop, so the target was always another row; a section with
none had nothing to aim at, and a section you have just made is empty by
definition, which is exactly when you want to put something in it. The
board has taken a drop on the column since it was written. The list now
does the same: the section is the target, and a drop on it puts the task at
the end. Dropping on a row still means "here exactly" and stops there
rather than bubbling to the section behind it.

The unsectioned block at the top is a target too, which is how a task comes
back *out* of a section — previously only possible if some other loose task
happened to be there to aim at.

**And the page said "Projektet findes ikke, eller du har ikke adgang til
det" for three different things.** `project` started null with no loading
state, so that sentence was what you saw before the first request returned,
what you saw when it failed, and what you saw when you genuinely had no
access — and the effect never asked again, so the wrong one stayed until
somebody reloaded. That is very likely what was reported: the container was
restarted twice while people had the app open, and a request that lands
mid-restart leaves the page insisting the project does not exist.

Four states now. Loading renders nothing, as the shell does. 404 keeps the
old sentence, since the server deliberately answers 404 for both "no such
project" and "not yours" and the interface must not guess between them.
Anything else says the request failed and offers to try again.

What is *not* a bug: a viewer cannot create or move tasks. Checked at the
API — an editor invited by link gets 201 on create, 201 on quick add and
200 on a move into a section; a viewer gets 404 on all of them, which is
the rule working. If the person reporting this cannot add tasks, their
membership is viewer rather than editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:04:55 +02:00
Kristian WindandClaude Opus 5 8b1f374855 test(e2e): drive a member opening a project, not just seeing it in the sidebar
The invite test proved the shared project reached the invitee's sidebar and
stopped there. A member's project page is a different path from an owner's
— different role, different guards — and it was never opened by anything.

It also matters that the page shows "Projektet findes ikke, eller du har
ikke adgang til det" while it is still loading, because `project` starts
null and there is no loading state. A failed load and a slow one look
identical, which makes this exactly the kind of thing a person reports and
a test suite cannot see.

Signs in as the invited account in its own context and clicks through to
the project, asserting the heading renders and that message is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:54:26 +02:00
Kristian WindandClaude Opus 5 550f420c59 feat(sidebar): colours you can choose, and projects indented under their group
`projects.color` has been in the schema since the first migration, has
defaulted to 'graphite' on every row ever written, and nothing has ever
read it. There was no palette, no name-to-colour mapping, and the dot in
the sidebar was hardcoded to a border grey. So this is not "add a picker";
it is implementing colour for the first time.

Names, not hex values. A hex in ten thousand rows is a decision nobody can
revisit; a name is resolved when the interface paints and can be retuned
for a new theme or a new display. It is also the difference between a
database that stores a fact and one that stores a rendering. The set is
closed and refused at the edge — an unknown name would be stored happily
and painted as the default, which looks like a colour that did not save.

One palette across all five themes, and this is the one place in app.css
that is not redefined per theme. Everything else there is text, a ground or
a border and has to be measured against what sits behind it. These are
small solid marks beside a label that already carries the meaning: what
they owe is to be told apart, and to be visible on both a near-black and a
white ground, which is why all ten sit in the mid-tone band. It also keeps
them out of the trap the themes are in — a colour added to four blocks and
forgotten in the fifth looks finished and falls back silently. There is no
fifth block to forget.

The list is written down three times: Go for validation, colors.js for the
picker, app.css for the tokens that paint. So a test reads the other two —
the same small-scanner approach openapi_test.go takes to the spec — and
fails if the server accepts a colour nothing can draw, or the picker offers
one the server refuses.

Groups get a colour of their own (migration 0006) and projects inside a
group are indented. The indent is to the group's *name*, not to its dot:
the eye follows the text, and lining the projects up under the dot leaves
them looking a step short of where the heading starts. The drop line is
indented with them, so a drop inside a group reads as landing in the group.

The group's dot is the control as well as the preview — you press the thing
you are about to change, as on the project page. That was not the first
design: a third text action, "Farve", sat beside Omdøb and Slet, and those
carry `opacity: 0` rather than `display: none`, so they take their width
whether or not you can see them. A group called "Arbejde" rendered as
"ARBEJD". Moving the control onto the dot gave the name its room back.

The smoke test measures the indent at the content edge rather than the
element's, because the indent is padding and the box does not move — which
is how the first version of that assertion passed 12 against 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:50:20 +02:00
Kristian WindandClaude Opus 5 e8ebd0fa78 feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the
beginning and there was no such thing. There are exactly two ways an
account has ever come into existence: the first admin at setup, and
somebody accepting an invite to a project. So the only way to add a person
was to share a project with them first.

The store has been ready the whole time. CreateInvite already documents
that "projectID may be empty, which invites somebody to the instance
rather than to a particular project", and AcceptInvite already has the
branch that grants no membership when there is none. requireAdmin has
existed in the middleware and was used nowhere. This wires those three
together and puts a page on them.

An invite rather than an account with a password the administrator picks.
A password typed here would be known to two people before it was ever
used, and would then have to travel to the second one by some channel
nobody has thought about. No name is asked for either — the signup form
asks the person, and a name an administrator guesses is a name somebody
has to correct.

Two refusals keep the instance administrable: the last administrator
cannot be demoted or deleted, and nobody can delete the account they are
signed in as. Both are enforced on the server; the interface simply does
not draw the buttons on your own row, because a button whose only purpose
is to be refused wastes a click to teach a rule. The last-admin refusal
gets its own error code, because "that already exists" is not what
happened and this is the one refusal a person needs explained.

Deleting an account is the only hard delete in this database, and building
the confirmation is what turned up how wide it goes. `projects.owner_id`
cascades, which is expected. But `tasks.created_by` cascades too — so
removing a colleague deletes every task they ever wrote, including in
projects somebody else owns. The count in the list says so, and the
confirmation says it in words. Changing that to ON DELETE SET NULL, so the
work stays and loses its author, means rebuilding the tasks table and its
FTS triggers: worth doing, not worth doing quietly while adding a screen.
It is written down in the store instead.

Pending invites are listed and can be withdrawn. Only the token's hash is
stored, so a link sent to the wrong address cannot be looked up and
resent — withdrawing it is the only way to stop it before it expires.

Six API tests for the guards and the counts, and two smoke tests: the
whole loop from invite to a second browser creating the account and
appearing in the list as somebody who is not an administrator, and the
absence of buttons on your own row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:10:46 +02:00
Kristian WindandClaude Opus 5 a8b453843d fix(sidebar): Omdøb og Slet lå oven i hinanden i et gruppehoved
Release / Build and publish image (push) Failing after 9m53s
The "+" beside "Projekter" is a 20px square icon button, and the rule that
makes it one was written as `.group-head > button`. A group's heading is
also a `.group-head`, and it has two buttons in it that are words rather
than icons — so Omdøb and Slet were each squeezed into a 20px box, where
they overflowed and were drawn on top of each other.

They still worked. Every assertion in the smoke test clicks by accessible
name, which is unaffected by a button being the wrong size, so the whole
suite passed on a heading that read "0 Om…Slet". It took a screenshot from
somebody actually using it.

The selector now asks for the class instead of for "a button in a
heading", so the next text button added there is safe by default rather
than by remembering.

And the smoke test measures the row: each button must start at or after
the previous one's right edge. A layout assertion is unusual here on
purpose — the rest of the file deliberately tests behaviour — but this is
a failure mode behaviour cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:53:14 +02:00
Kristian WindandClaude Opus 5 a2f4de02fc chore: stop tracking the compiled shots binary
Release / Build and publish image (push) Failing after 10m0s
8.9 MB of macOS binary in the repository root, committed by accident in
a8e523b. The source in tools/shots is the thing that matters, and it is
still there.

Ignored as well as removed, because `go build ./tools/shots` without -o
writes the binary to exactly this path — which is how it got committed the
first time, and would be how it got committed the next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:44:19 +02:00
Kristian WindandClaude Opus 5 4276892590 feat(auth): see where you are signed in, and end one of them
`last_seen_at` has been written on every request since sessions existed —
throttled to once a minute, deliberately, so a list could one day say
"denne enhed, lige nu" — and nothing has ever read it. The store even had
ListSessions. What was missing was two routes and a panel.

GET /auth/sessions lists them, most recently used first, with the current
one marked so nobody has to work out which row is the browser they are
reading it in. DELETE /auth/sessions/{id} ends one. Ending the current
session is allowed and is simply logging out: refusing it would be a rule
with no reason behind it, and somebody clearing every device wants this
one gone too.

Both are behind requireSession rather than the ambient auth, the same as
the API tokens. A leaked token that could list its owner's devices and end
their sessions turns a theft into a lockout.

The delete is scoped in the statement rather than checked and then run.
An id read in one step and deleted in the next is a window, and the whole
reason somebody is on this screen is that they think another person is in
their account.

The device names are a handful of substring tests, not a parsing library.
The question this answers is "is one of these me?", and for that a wrong
guess between two Chromium forks costs nothing while a dependency that has
to be kept current costs something every year. Order is the whole
correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome,
and Chrome says Safari. The raw string is sent alongside and sits in the
row's title, so anything the summary gets wrong is still visible — and
something that is not a browser at all shows its own name rather than
"Ukendt", because a CalDAV client is a real session.

Times are relative. The question is "was that me, just now?", and an
absolute timestamp makes the reader do the subtraction; past a week it
switches to a date, because "for 23 dage siden" is a number nobody can
place.

The smoke test signs in a second time in a second browser context, ends
that session from the first, and checks the other browser is really signed
out — and that this one is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:43:49 +02:00
Kristian WindandClaude Opus 5 6b6b838167 feat(views): "Venter på andre", and a project you share stays yours
`assignee_id` has been on tasks since sharing was built and delegating has
been possible from the drawer since yesterday. What was missing was
anywhere to see it: work handed to somebody else left your lists and
appeared in nothing.

GET /delegated returns everything assigned to somebody who is not you, in
the projects you can see, grouped by the person. Grouped on the server
rather than flat, because the question is about people — "what is Anders
sitting on" — and a client cannot group it itself: it would need the name
behind an assignee id from a project it does not happen to have open, and
there is no way to ask for that. The names come back with the tasks, so
one request is one screen.

The filter is a field of its own rather than a sentinel in AssigneeID.
"What is on my plate" and "what have I handed over" are different
questions, and one string that means an id most of the time and a magic
word the rest is a field somebody eventually passes a real id to.

An assignee whose account is gone is named "Ukendt" rather than dropped.
Losing the row would make work disappear from the one view that exists to
stop work disappearing.

The sidebar entry only appears where delegating is possible at all. On an
instance with one person it could never have anything in it, and a
permanent empty entry is a question the sidebar keeps asking and answering
"no".

Building it turned up a bug in the split above it. "Projekter" and "Delt
med mig" were divided on `shared`, which only means the project has more
than one member — so inviting somebody to a project of your own moved it
under "Delt med mig", where it lost its place in the order, stopped being
draggable, and could not be filed under a group. It is divided on
ownership now, which is what the two headings have always said. Sharing
your own project is not the same event as somebody sharing theirs with
you.

The smoke test now follows the whole loop in one go: invite, accept in a
second browser, assign a task to the person who just joined, and find it
under their name — with the shared project still under "Projekter" on the
way past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:36:37 +02:00
Kristian WindandClaude Opus 5 34b718529c fix(auth): the two links sent by email now go somewhere
An invite has always emailed `/invite?token=…` and "Glemt adgangskode?"
has always emailed `/reset?token=…`. Neither address existed. The Go
binary serves the app shell for anything it does not recognise, the shell
found no session, and both links opened the plain sign-in form — asking an
invited person to log into an account that did not exist yet, and asking
somebody who had forgotten their password for their password.

Both APIs were finished. `POST /auth/signup` takes the invite token, reads
the email address off the invite rather than the form — so a link cannot
be redirected to somebody else — creates the account, accepts the invite
and starts the session, all in one transaction. `POST
/auth/password/reset` takes the reset token and ends every session the
account had. Nothing was missing but two pages.

They are branches of SignIn rather than screens of their own, which is
what that component already says it is for: one decision tree, so a person
is never redirected mid-authentication. The routes exist because a link
has to be an address, and each renders the same component; it reads the
token out of the URL itself.

The reset does not sign you in afterwards. The whole point of ending every
session is that somebody may have been in the account, and quietly opening
a new one here would undo the half of that which matters.

Two states that are easy to leave out, and both are reachable in one click
from an old email: a link with no token says so instead of showing a form
that cannot succeed, and a link opened while already signed in as somebody
else offers to log out rather than creating a second account for a person
who is right there in the first one.

The smoke test walks the whole invite: share a project, read the link out
of the panel that shows it when there is no mail server, open it in a
second browser context, create the account, and find the shared project in
the sidebar. A second context and not a second page — inheriting the
session would test the one case that never happens, which is what it did
on the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:30:21 +02:00
Kristian WindandClaude Opus 5 c14559ed0c feat: project groups, a month on Kommende, and dragging things where they go
The three the handoff named as next. They are one commit because they are
one mechanism: all three end in something being dragged, and a drop target
cannot tell what is coming without the module they now share.

Groups are foldable headings over the projects in the sidebar. A table
rather than a `group_name` column on the project, because a group has to
be renamable in one write, has to survive being emptied, and has to
remember whether it is folded — none of which a string repeated on every
project row can do.

Folded state lives on the row, not in localStorage. The sidebar's *width*
is a property of the screen you are sitting at, which is why that one is
per browser; a folded group is a statement about the group — "this is the
part of my work I am not in right now" — and it is true on the laptop and
on the desktop. There is nobody else's answer to disagree with either: a
group belongs to exactly one person.

A shared project cannot be filed. `group_id` is a column on the project
row, so a member grouping somebody else's project would move it in the
owner's sidebar too — the same reason `sort_order` is owner-scoped. The
listing reports the column only to the owner, so a viewer is never told
where somebody else keeps it.

Deleting a group takes the heading and leaves the projects, and the
sidebar decides where a project renders by whether its group still
exists rather than by the id alone. A group deleted in another tab
arrives as one event and not one per project, so for a moment the
projects still name a heading that is no longer drawn — filing by the raw
id would drop them out of the sidebar entirely.

Kommende gains the month grid the project pages already had, behind a
Liste/Kalender toggle. The two views ask for different things — seven days
from today against six whole weeks around an arbitrary month — so each
loads its own, and the grid reports which dates it is showing rather than
having the page guess. Which view is remembered in localStorage: a month
of seven columns needs room, and the answer on a phone is not the answer
on a wide monitor.

And a task can now be dropped on a project in the sidebar, on a day in
Kommende, or on a cell in any month grid. Onto a project it is a *move*,
not a field: `sort_order` belongs to the project, so it has to find a
place among that project's tasks and leave its old section behind.

Each drag now carries its own MIME type. `getData` is deliberately
unreadable while a drag is in the air — the browser hands the payload over
on drop and not before, so a page cannot read what is being dragged across
it — and `dataTransfer.types` is all a target has to decide with. Deciding
is the point: calling preventDefault on a drag you cannot accept is what
turns "no" into a silent, wrong drop. The sidebar takes a project and a
task on the same row and has to tell them apart.

Two things fell out of building it. The month grid has always had one cell
narrower than the other six: `.today` styled the "I dag" button in the
header, and the day cell for today wears the same class, so `margin-left:
auto` was shrinking a grid item to its content. It is scoped to the header
now. And a project page listed whatever was in the store rather than what
belonged to that project, which did not matter until a task could leave a
project while you were looking at it.

Covered by eight new API tests and two new smoke tests, and the smoke tests
drive the drags for real — the type check they exercise exists only while
a drag is in flight, so nothing else can reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:25:10 +02:00
Kristian WindandClaude Opus 5 2300f1dc3b fix(theme): the original two themes were the least readable of the five
Adding three themes meant measuring them, and measuring them meant
measuring the two that shipped. They came last.

--ink-faint is the tier that decides whether an interface is readable:
dates, counts, hints and placeholders all live there, and it is the first
thing to disappear. Dark sat at 3.8:1 against its ground and light at
3.1:1, where AA asks for 4.5. Light's accent — which is text as often as
it is a background, on links and on "open the project" — sat at 3.8:1
too.

All five now clear 4.5:1 at every tier. The numbers are in the code
beside the colours, because the next person to nudge a hex should measure
rather than look: this is exactly the kind of change that looks fine to
whoever makes it.

Left alone earlier today as a deliberate scope call — recolouring
somebody's app as a side effect of adding themes is not a side effect
anyone asked for — and done now that it was asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 18:29:56 +02:00
Kristian WindandClaude Opus 5 1529c87231 feat(theme): five themes, and a picker that shows them
Release / Build and publish image (push) Failing after 9m58s
Two themes and a toggle between them. Now five, chosen from a grid under
Settings → Konto: the two that existed, a warm dark (Skumring) with amber
where the green was, a warm light (Papir), and a high-contrast one.

Each swatch is painted in its own theme rather than described, so the
choice is made by looking. data-theme sits on the swatch and not on the
card — it redefines --ink too, and a card wearing a dark theme's ink on a
light page has a name nobody can read.

Every theme states its full palette rather than inheriting and patching.
A partial theme looks finished until somebody adds a token, at which
point four themes silently fall back to a colour from a fifth. That is
also why the dark palette is repeated under [data-theme='dark']: :root
carries it as the default and :root only ever matches <html>, so a swatch
asking for it by name would otherwise show whatever the page was wearing.

The theme-color meta is updated too, so an installed PWA's frame and a
phone's address bar follow rather than staying whichever colour shipped
in the markup.

Contrast measured for all five. The new ones clear 4.5:1 down to the
quietest tier — which is where dates and counts live, and the first thing
to disappear. Kontrast reaches 8.9:1 there. The two original themes are
left as they are: their faint ink sits at 3.1–3.8, which is worth
revisiting, but not as a side effect of adding colours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 f15c440942 feat(realtime): keep projects and labels current too, not only tasks
The socket carried task events and nothing else, so a project or a label
created anywhere — another tab, another device, Claude through the
connector — did not appear until a reload. Tasks updated live and the
sidebar did not, which reads as the sidebar being broken rather than as a
gap in what is published.

Projects and labels go to the *user* rather than to a project. A project
that has just been created has no subscribers, because nobody was
watching a thing that did not exist a moment ago.

Labels are announced as "something changed" and re-read, rather than sent
as a payload. The sidebar wants each label's task count, which no single
event carries and which moves whenever a task gains or loses a label —
including through quick add, where "@regnskab" invents the label inside a
task write and no label event is sent at all. Comparing the label lists
on a task change is what catches that, without re-reading on every ticked
checkbox.

And projects now go through an upsert, because the same row arrives twice
— once as the response to the request that created it, once over the
socket. Appending both puts two ids into a keyed {#each}, which throws
and stops the sidebar rendering. The tasks already had that guard; adding
the project event without it reintroduced the identical bug, and the
smoke test caught it on the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 d37d240625 feat(search): find a task by its label
Search covered task text only, so "regnskab" found nothing with the task
labelled @regnskab sitting in front of you — which reads as the search
box having lost it.

A label is often the only thing you remember. "That thing about the
accounts" is a search for @regnskab, not for any word in the title.

The label side is a plain lowercase match rather than a second FTS index.
Labels are short, and their names are typed by the same person now
searching for them, so there is no spelling to be generous about — the
diacritic folding that earns its keep on task text does not earn a second
index here.

The join sits inside the WHERE the visibility rules already build, so
somebody else's label cannot pull their task into your results. There is
a test for that, because it is the kind of thing an OR is good at
breaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 3b521b463c feat(projects): add, rename and delete sections
The board has drawn a column per section since it was written and there
was no way to make one. Sections could only be created by the Todoist
importer or a template — so an ordinary project could never have a
heading in it at all.

Add, rename and delete, in the list view where the headings already are.
The controls appear on hover rather than sitting beside the heading
permanently: a heading with two buttons always next to it stops reading
as a heading.

Deleting says how many tasks are in the section and that they will
survive it, because the API keeps them and drops their section — which is
the right behaviour and not what "delete section" sounds like. The list
is re-read afterwards, since those tasks move to the unsectioned group
and nothing else would tell the view they had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 9056c44c9a feat(tasks): move, repeat, delegate and re-read a task from its drawer
Four things you could not do to a task you had open, and one that had
never worked at all.

**Project, recurrence and assignee** are now fields in the drawer. A task
created in the wrong place could only be fixed by dragging it, a repeat
could only be set by typing it into the title when the task was created,
and delegation had no interface whatsoever despite assignee_id being in
the API from the start. The assignee only appears on a project with
somebody else in it, because on your own there is nobody to choose.

**The title is read the way quick add reads it.** Typing "i morgen kl 14
p1" into a title used to leave it as those words, so where you typed a
sentence decided what it meant. The parse runs on the server — the
grammar is Danish and English and lives there — and applies only when it
consumed something. What it took is said out loud, and the fields on
screen are updated to match: the drawer otherwise seeds its inputs once
per task so a save cannot overwrite what you are typing, and that rule
has to bend exactly here, where the save changed the very fields in front
of you.

**And the member list has never worked.** The share panel, and now the
assignee field, ask for it. Every call errored: the ORDER BY named an
expression, and SQLite only accepts result-set columns across a UNION, so
it failed at query time with "1st ORDER BY term does not match any column
in the result set". Found by the smoke test that opens a task, which is
the second real bug that suite has caught by watching for failed requests
rather than only for what renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 afc46a5c20 fix(api): do the things the API says yes to
Release / Build and publish image (push) Failing after 9m58s
Three reports from somebody driving the app through the MCP connector,
all the same shape: an argument accepted, an OK returned, and nothing
done.

**project_id on an update never moved the task.** Both the REST handler
and the MCP tool read it, both checked the caller's rights on the
destination — which reads as "understood" — and then neither passed it
on, because store.TaskUpdate has no such field: a project change is a
move, since sort_order is per project. So the task stayed where it was
and the answer said OK. Moving a task between projects was impossible
except by creating it again and closing the old one, which is what the
person ended up doing. Both paths now go through one helper, so they
cannot drift apart again.

**An unknown #project vanished without a word.** The parser reads
"#3Dekoration" correctly; the lookup then fails, the task goes to the
Inbox, and the name is gone from the title with nothing anywhere saying
why. Still not an error — a thought should not be thrown away over a typo
— but quick-add now returns unknown_project, and the MCP tool returns a
note written for a model to relay. The old behaviour was kind in the web
interface, where you see the row appear in the wrong place and fix it. In
a chat nobody sees the row.

**Sub-tasks were unreachable over MCP.** create_task had no parent_id,
though the REST API has had it all along, so importing a parent with
seven children produced eight unrelated tasks with the parent's name
prefixed onto each. A sub-task joins its parent's project — the two
cannot disagree — and a second level is refused rather than silently
flattened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:24:58 +02:00
Kristian WindandClaude Opus 5 8c748c2a88 feat(sidebar): drag it wider, fold it away, and have it stay that way
The sidebar was 268px and always there. On a wide monitor that wastes the
room; on a laptop it takes room the list needs.

The width drags from a handle on the border — 7px of target over a 1px
line, because a 1px target is a target you miss — and the whole column
folds away from a button in the topbar. Both are remembered.

In localStorage rather than on the account: this is a property of the
screen you are sitting at, not of you. The same person on a laptop and a
wide monitor wants different answers, and syncing it would make one of
them wrong every time they switch.

The handle is also a slider for the keyboard. Arrow keys move it, Home
puts it back, and so does a double-click — a drag handle with no keyboard
equivalent is a setting some people simply cannot reach.

Folding is desktop-only, and the media query says so rather than leaving
it to chance: below the breakpoint the sidebar is a drawer that opens over
the page, and a collapsed drawer would open to nothing. Clamped to
200–480px in the store, so a stored value from an older build cannot make
it unusable either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:09:16 +02:00
Kristian WindandClaude Opus 5 c4e9e1519b feat(projects): drag the sidebar into the order you want
Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.

POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.

Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:59:31 +02:00
Kristian WindandClaude Opus 5 3f4af6c0ab fix: stop the app shell answering for /.well-known/
Release / Build and publish image (push) Failing after 9m56s
Adding the connector to Claude failed with "Couldn't register with
Verdande's sign-in service", on a server that has no sign-in service and
needs none.

Claude probes /.well-known/oauth-authorization-server before connecting to
an MCP server. The SPA fallback answered 200 with a page of HTML — which
reads as "yes, there is an authorization server here" — so it went on to
dynamic client registration and failed there instead of concluding the
obvious thing, which is that this server does not use OAuth and the key in
the URL is the whole credential.

/.well-known/ is reserved by RFC 8615 and is never a route in this app. A
404 is the true answer, and it is also the useful one: the client stops
asking and uses the URL as given.

This is the third time the catch-all has caused a confusing failure by
saying 200 to something that does not exist — /mcp itself was the second.
The fallback should hand back the shell for routes the app might have, not
for every path on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:21:35 +02:00
Kristian WindandClaude Opus 5 7ab68b0b58 fix(projects): keep the heading, and make the sidebar test mean what it says
Renaming replaced the project page's h1 with an input, so a page an owner
opened had no heading at all. The sidebar smoke test caught it on CI — and
it was right to: every other view here has a heading, and it is what a
screen reader announces on arrival.

The heading stays and becomes a field when clicked. Same rename, one more
click, and the page keeps its outline.

The more interesting half is why that passed locally. The test clicked a
link and immediately asserted a heading was visible — and `toBeVisible`
was being satisfied by the heading of the page it had just left, before
the SPA swapped it out. On this laptop the old h1 lingered long enough to
answer; on a slower runner it did not. So the test did not check that the
new page rendered, it checked that some page had at some point.

waitForURL closes that. Verified by putting the broken heading back with
the strengthened test in place: it now fails locally with the same message
CI gave.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:10:00 +02:00
Kristian WindandClaude Opus 5 8a6e1bc39e feat(projects): rename, delete, and a trash you can actually reach
A project could be created and shared and never renamed or deleted. Both
have worked through the API since the beginning; there was simply nothing
to click.

The heading is now the field — a heading that happens to be editable
rather than a form that looks like one — and saves on blur, with Escape
putting the old name back. Owner only, and the Inbox keeps its plain
heading because it cannot be either renamed away or deleted.

Deleting needed more than a button. It is soft, with thirty days to
change your mind, and restoring has been possible the whole time — from
an id, which is exactly what you no longer have once the project has gone
from the screen. A recovery window nobody can reach is not one. So
GET /trash/projects lists what you have deleted, with how many tasks went
with it and how long is left, and Settings → Data has the button.

Owned, not shared: a member of a project somebody else deleted has no
business bringing it back, and there is a test that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:02:41 +02:00
Kristian WindandClaude Opus 5 0dbea0ea4b feat: make it possible to see which version is actually running
Release / Build and publish image (push) Failing after 9m56s
"I have updated and restarted, but I cannot tell whether it worked" is
not a question the panel could answer. The version was on the startup
line and in the settings page, and nowhere you can reach from outside.

/healthz carries it now. That is the one endpoint answering without a
session, which makes it the only way to ask the question from a terminal
or from the panel itself. The "listening" line repeats it too, because
that is the line the Rune's done_regex matches and therefore the one
sitting at the bottom of the console after a deploy.

The update notice could also never have worked. It reads GitHub's
releases/latest, and this repository had only tags — a tag is not a
release, and that endpoint answers 404 for a repository without any. The
release workflow creates one now, after the image is pushed so a release
never announces a version that failed to build.

VERDANDE_UPDATE_CHECK is a Rune variable, so it can be switched on
without editing the container. Still off by default: it is an outbound
request, and that is a choice to make rather than a default to inherit.

Rune version 1 -> 2, so the panel offers the update.

Note that the check still cannot see anything while the repository is
private — releases/latest is 404 anonymously, exactly as the GHCR package
was. Making the repo public, or teaching the check to carry a token, is a
separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:35:36 +02:00
Kristian WindandClaude Opus 5 78ec9b08a1 fix: say the true thing when Gmail, AI or mail is not set up
Two bugs with one shape: the interface confidently told you something
that was not so.

Connecting Gmail without a registered OAuth client said "Det er der
allerede." Thirteen different situations answered a plain `conflict`, and
the frontend renders that as "that already exists" — which for four of
them is not vague but wrong. An unconfigured Gmail is not a Gmail that is
already connected; a second factor that is off is not one that is on; the
Inbox is not a project that already exists somewhere else. Those four
have their own codes now, and their own sentences.

The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM
defaults to verdande@localhost and the domain was taken from it
unconditionally, so an instance with no mail server handed out an address
that looks real, can never receive anything, and gives no hint which of
the two it is. The default no longer wins over the public hostname, and
the response now carries whether a mail server exists at all — because
even with the right domain, nothing routes to it until one does.

errors.go has always claimed the code list is kept in one place "so the
frontend's mapping can be checked against it". Nothing checked it, and
the mapping was missing bad_request. Now a test reads both files and
fails on a code with no message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:35:36 +02:00
Kristian WindandClaude Opus 5 7e28d8bb92 chore: untrack a vite temp file, and stop it coming back
Release / Build and publish image (push) Failing after 9m56s
vite.config.js.timestamp-*.mjs is written while bundling the config and
removed again — unless the process is killed, which is exactly what
happens during a hung build. One had been committed by accident; five
more appeared while working out why the build was hanging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 e63d852847 feat(mcp): let the connector address carry its own key
verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.

Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.

So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.

It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.

The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 d7e250f860 docs(rune): a rune does not update itself, and the server need not be deleted
Two corrections after checking against Yggdrasil's own behaviour rather
than guessing at it.

The advice to delete the failed server and make another was wrong. The
image reference is read from the rune at container-create time, and both
Start and Restart recreate the container — so once the image can be
fetched, pressing Start on the server you already have is enough.

And a rune does not pick up a changed manifest on its own. The panel
writes the rune row from a UI action only; neither a restart nor a timer
does it. So a new manifest needs Runes → verdande → Update, and the
catalogue list is cached in memory for ten minutes, which is long enough
that two attempts in a row can both hand back the old version.

The host-pull fallback now says what it costs: the panel re-pulls on
every recreate, fails quietly, and carries on with the local image, so a
new release looks like it rolled out when it did not. It is a stopgap for
keeping the package private, not a way to run this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:27:02 +02:00
Kristian WindandClaude Opus 5 a8a29a963d docs: the registry has to be public, and docker login was the wrong advice
Installing the Rune still failed after v0.2.0 was published, with the same
message as before:

    create container: Error response from daemon:
    No such image: ghcr.io/kristianwind/verdande:latest

The advice in these pages — log the host's Docker daemon in — cannot
work, and Yggdrasil's source says why. Its PullImage calls the Docker
SDK's ImagePull with an empty image.PullOptions{}; RegistryAuth is never
set, and there are no registry credentials anywhere in the panel. A
private image answers 401. pullImageRetry discards that error on purpose
("second failure surfaces at create"), and ContainerCreate — which only
ever looks locally — then reports the image as missing. The message is a
liar: the tag is in the registry the whole time.

docker login does not change this. It is client-side: the daemon stores
no credentials, the CLI sends them itself with each request, and
Yggdrasil is a different client that sends none.

So the package has to be public, and the pages now say that rather than
offering a login that does nothing. Keeping it private is still possible
but is now described as what it is: putting the image on the host by hand
before creating the server and again after every release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:55:56 +02:00
204 changed files with 27014 additions and 1959 deletions
+11 -5
View File
@@ -79,12 +79,18 @@ jobs:
working-directory: web
run: npm ci --no-audit --no-fund
# Chromium only. A smoke test exists to catch a build that does not work at
# all; running it three times in three engines finds the same breakage three
# times and costs three times as much.
- name: Install Chromium
# Chromium for the suite, WebKit for one test.
#
# A smoke test exists to catch a build that does not work at all, and running
# the whole thing in three engines finds the same breakage three times for
# three times the cost. Focus is the exception, and it took a real report to
# learn it: Safari ignores `autofocus` on anything inserted after the page has
# loaded, so every inline form in the app opened with no cursor in it. WebKit
# here is the same engine Safari is, and `webkit-forms` in the Playwright
# config greps a single test out of the suite to run in it.
- name: Install browsers
working-directory: web
run: npx playwright install --with-deps chromium
run: npx playwright install --with-deps chromium webkit
# The suite builds the frontend, embeds it and starts the real binary — see
# playwright.config.js. There is nothing to build here first.
+15 -1
View File
@@ -6,7 +6,9 @@ on:
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
permissions:
contents: read
# write rather than read: the job creates the GitHub Release the update notice
# reads. packages: write is for pushing the image.
contents: write
packages: write
jobs:
@@ -50,3 +52,15 @@ jobs:
VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
# A tag is not a release. The in-app update notice reads GitHub's
# releases/latest, which answers 404 for a repository that only has tags —
# so without this step the check can never find anything, however correctly
# it is switched on.
#
# Created after the image, so a release never announces a version that
# failed to build.
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
make_latest: true
+26
View File
@@ -11,6 +11,16 @@
# Frontend
node_modules/
# iCloud syncs ~/Documents, and it evicts what it thinks is cold — which for a
# dependency tree is nearly all of it. An evicted file blocks on read until it is
# downloaded again, and a download that stalls hangs the build for good: 0 % CPU,
# no output, forever. macOS never syncs anything ending in .nosync, so the tree
# lives there and `node_modules` is a symlink to it. Redo it after a fresh clone:
#
# mv node_modules node_modules.nosync && ln -s node_modules.nosync node_modules
#
# The real fix is a repo path outside a synced folder.
node_modules.nosync/
/web/.svelte-kit/
/web/build/
@@ -40,3 +50,19 @@ node_modules/
# Built documentation
/site_build/
# Vite writes these while bundling vite.config.js and removes them again — unless
# the process is killed, which during a hung build is exactly what happens. One
# was committed by accident.
/web/vite.config.js.timestamp-*.mjs
# A compiled `go build ./tools/shots` in the repo root. It was committed once by
# 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
+20 -3
View File
@@ -5,7 +5,17 @@
# manager and no libc — the attack surface of the running container is the binary.
# --- 1. the web interface ----------------------------------------------------
FROM node:22-alpine AS web
#
# Pinned to the *build* platform, not the target. What this stage produces is
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
# on — so building it once and copying it into both architectures is not an
# optimisation, it is the correct thing.
#
# Without the pin, buildx runs this stage once per target, which means running
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
# instruction the emulator does not implement, roughly one release in three. It
# looks like a broken build and is a broken emulator.
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src
COPY web/ ./
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
fi
# --- 2. the binary -----------------------------------------------------------
FROM golang:1.26-alpine AS build
#
# Also pinned to the build platform, and cross-compiled instead. Go does that
# natively and CGO is already off, so there is nothing to emulate — where
# building *in* the target architecture means running the whole Go toolchain
# under QEMU for one of the two.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src
@@ -37,11 +52,13 @@ COPY . .
COPY --from=web /src/build ./cmd/verdande/webbuild
ARG VERSION=dev
# Supplied by buildx per target. The default keeps a plain `docker build` working.
ARG TARGETARCH=amd64
# CGO off is what makes this binary static, and it is only possible because the
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
RUN CGO_ENABLED=0 GOOS=linux go build \
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-tags embedweb \
-trimpath \
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
+30 -12
View File
@@ -34,9 +34,13 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
@regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views.
- **Sharing.** Internal users are created by an admin; anyone else joins through an
invite link sent to their email. Owner, editor and viewer roles.
views, and foldable groups over the projects in the sidebar.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
everybody arrives through an invite link, either to a project or — from
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
by somebody else.
- **Live sync.** A change made by somebody else appears without a refresh.
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
@@ -60,12 +64,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project roles and permission checks | Done, tested |
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | Done, tested |
| Assignee shown on every task that is somebody else's | Done, smoke-tested |
| Activity log, visible per project | Done, smoke-tested |
| Change a member's role without removing them | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
| Task detail: description, sub-tasks, comments, files, reminders | Done |
| Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested |
| User administration: invite to the instance, promote, remove | Done, tested |
| Server errors kept in the database, readable after a restart | Done, tested |
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
| Invite and password-reset links that open the right page | Done, smoke-tested |
| Drag to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested |
| Foldable project groups in the sidebar | Done, tested |
| Month view on Upcoming, not only per project | Done, smoke-tested |
| Recurring tasks (RRULE, Danish + English) | Done, tested |
| Reminders, absolute and relative | Done, tested |
| Saved filters with a query language | Done, tested |
@@ -102,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. |
| ![A week on Upcoming](site/screenshots/week.png) | ![A task, opened](site/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
| ![The five themes](site/screenshots/themes.png) | ![Instance history](site/screenshots/history.png) |
| Five themes, all measured for contrast. | What has been done on the server. |
Regenerate these with `go run ./tools/shots` against a running instance.
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
the real binary, creates its own account, fills it with demo data and photographs
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
still works, and is the way to photograph an instance that is already running.)
## Running it
@@ -126,14 +149,6 @@ and the manifest ships in this repository. It runs anywhere Docker does as well.
### With Docker directly
The package is private, and an anonymous pull fails with `No such image` rather
than with a permission error. Log the host's Docker daemon in once, with a GitHub
token carrying the `read:packages` scope:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
```
```bash
docker run -d --name verdande \
-p 8080:8080 \
@@ -182,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`
+17 -1
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{
@@ -77,7 +89,11 @@ func run() error {
errc := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Addr)
// The version is repeated here rather than only on the "starting" line
// above: this is the line the Rune's done_regex matches, so it is the one
// sitting at the bottom of the panel console after a deploy — which is
// exactly when somebody wants to know what actually started.
log.Info("listening", "addr", cfg.Addr, "version", version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
+6
View File
@@ -52,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
| `forbidden` | 403 | Cross-site request, or admin needed. |
| `not_found` | 404 | Does not exist, or you cannot see it. |
| `conflict` | 409 | Already exists, or the state does not allow it. |
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
| `ai_not_configured` | 409 | No AI provider has been set up. |
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
| `validation_failed` | 422 | Check `fields`. |
| `rate_limited` | 429 | Too many attempts. |
| `internal_error` | 500 | Our fault. |
@@ -96,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
### Views, filters and labels
+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
+3 -1
View File
@@ -21,7 +21,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views.
board and calendar views, and foldable groups over the projects in the sidebar.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Live sync** — a change made by somebody else appears without a refresh.
+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.
+27 -4
View File
@@ -12,17 +12,40 @@ conversation.
## Connecting it
**Make a token.** **Settings → API tokens → New token**. It is shown once.
**Make a token** under **Settings → API tokens**. The page then shows the
connector address to go with it — token and all — which is the thing to copy:
**Add the connector.** In Claude, add a custom connector with:
```
https://todo.example.dk/mcp?key=vrd_…
```
- **URL**: `https://todo.example.dk/api/v1/mcp`
- **Authentication**: Bearer token, using the token you just made
**Add the connector** in Claude with that address, and nothing else. The dialog
asks for a name and a URL; leave the OAuth fields empty.
!!! note "Why the key is in the address"
The dialog has no field for a bearer token — only OAuth client credentials —
so a header cannot be configured from it. The address *is* the credential
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
Treat it like a password: anyone holding it reaches what you reach. The
server logs request paths without their query strings, so it does not end up
in the log.
!!! warning "It has to be reachable from the internet"
Claude connects from Anthropic's side, not from your browser. An instance only
reachable on your LAN or over a VPN cannot be used this way.
### With a header instead
Anything that *can* send a header should, and use the API path:
```bash
curl -H "Authorization: Bearer vrd_…" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
https://todo.example.dk/api/v1/mcp
```
Both endpoints run the same server and reach the same things.
## What Claude can do
| Tool | What it does |
+1624 -6
View File
File diff suppressed because it is too large Load Diff
+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.
+46 -16
View File
@@ -3,33 +3,53 @@
verdande ships with a Rune manifest for
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
## Before you start: the registry is private
The image lives at `ghcr.io/kristianwind/verdande` and the package is **private**,
so an anonymous pull fails with a message that reads as though the image does not
exist at all:
## If the image will not pull
```
create container: Error response from daemon:
No such image: ghcr.io/kristianwind/verdande:latest
```
That is a permission error wearing the wrong hat. The Docker daemon **on the
Yggdrasil host** has to be logged in once:
This message is a liar. It does not mean the tag is missing from the registry —
it means the image is not on the host, and the panel's attempt to fetch it failed
without saying so.
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
looks locally — then reports the image as missing.
!!! warning "`docker login` on the host does not help"
`docker login` is client-side. The daemon stores no credentials; the CLI
sends them itself with each request. Yggdrasil is a different client and
sends none, so logging in changes nothing for the panel.
**Make the package public.** Under
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
→ *Change visibility*. The repository stays private; only the built image becomes
readable, which is what the manifest and these pages have always assumed.
Once it is public, press **Start** on the server you already have. There is no
need to delete it and make another: the image reference is read from the rune at
container-create time, and both Start and Restart recreate the container.
To keep the package private you have to put the image on the host yourself,
before creating the server, and again after every release:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
sudo docker login ghcr.io -u kristianwind
sudo docker pull ghcr.io/kristianwind/verdande:latest
```
`GHCR_TOKEN` is a GitHub personal access token with the **`read:packages`** scope
and nothing else. The login is stored in `~/.docker/config.json` for whichever
user runs the daemon — if Yggdrasil runs as root or through systemd, log in as
that user, not as yourself.
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
panel's own pull still fails silently; create simply finds the image already
there.
!!! tip "Or make the package public"
Under **Packages → verdande → Package settings → Change visibility**. The
repository can stay private; only the built image becomes readable. Then no
login is needed anywhere and this section stops applying.
!!! danger "This hides updates"
The panel re-pulls on every recreate, fails quietly, and carries on with the
local image. A new release then *looks* like it rolled out and did not. For
something you intend to update, public is the right answer and host-pull is
the stopgap.
## Installing
@@ -40,6 +60,16 @@ that user, not as yourself.
3. Fill in the settings below.
4. Start it, open the URL, create the first account.
!!! note "Updating the rune is a manual step"
A rune does not update itself when this manifest changes. The panel writes
the rune row from a UI action only — neither a restart nor a timer does it —
so pick up a new manifest with **Runes → verdande → Update**, or install it
again from Browse GitHub.
The catalogue list is cached in memory for ten minutes, so two attempts in a
row can both hand you the old version. Recent panel versions show the list's
age with a *fetch again* link.
## Settings
| Setting | What to put |
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+6 -9
View File
@@ -5,15 +5,12 @@ cache, no queue and no second process.
## Docker
!!! warning "Log in to the registry first"
The package is private, and an anonymous pull fails with `No such image`
rather than with a permission error. Once per host:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
```
`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
!!! note "If the pull is refused"
The GHCR package is public, so this needs no credentials. If it is ever set
back to private, `docker login ghcr.io` with a token carrying the
`read:packages` scope before pulling — and see
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
fails differently and much more confusingly under Yggdrasil.
```bash
docker run -d --name verdande \
+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
}
+111
View File
@@ -0,0 +1,111 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/store"
)
// What has happened on this server, across every project.
//
// The per-project history under /projects/{id}/activity is the same rows read by
// the people who work in that project. This is the administrator's version of the
// question, and it is a different one: an administrator is not necessarily a member
// of the project they need to look into, and "which account did that" cannot be
// asked one project at a time.
//
// It sits beside the error log for a reason. That one is the half about what broke;
// this is the half about what was done, and an instance had only ever had the first.
type auditEntryJSON struct {
ID string `json:"id"`
At string `json:"at"`
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
TaskID string `json:"task_id,omitempty"`
// UserID and UserName are absent when the account has been deleted. The row
// stays — an audit log that lost its rows when somebody left would be missing
// exactly the part worth keeping.
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Event string `json:"event"`
Payload map[string]any `json:"payload,omitempty"`
}
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := store.AuditFilter{
UserID: q.Get("user_id"),
ProjectID: q.Get("project_id"),
Event: q.Get("event"),
Limit: parseLimit(q.Get("limit"), 100, 500),
}
if cursor := q.Get("before"); cursor != "" {
at, id, ok := parseAuditCursor(cursor)
if !ok {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"before must be <unix>:<id>, as next_cursor returns it")
return
}
f.BeforeAt, f.BeforeID = at, id
}
entries, err := s.db.ListAuditLog(r.Context(), f)
if err != nil {
s.internal(w, r, "list audit log", err)
return
}
out := make([]auditEntryJSON, 0, len(entries))
for _, a := range entries {
out = append(out, auditEntryJSON{
ID: a.ID, At: a.CreatedAt.Format(time.RFC3339),
ProjectID: a.ProjectID, ProjectName: a.ProjectName, TaskID: a.TaskID,
UserID: a.UserID, UserName: a.UserName, Event: a.Event, Payload: a.Payload,
})
}
body := map[string]any{"activity": out}
// A cursor only when the page was full. A short page is the end of the log, and
// a cursor there invites one more request that can only come back empty.
if len(entries) == f.Limit && f.Limit > 0 {
last := entries[len(entries)-1]
body["next_cursor"] = strconv.FormatInt(last.CreatedAt.Unix(), 10) + ":" + last.ID
}
writeJSON(w, http.StatusOK, body)
}
// handleAuditEvents is what the event filter is drawn from: the names that occur,
// with counts. Offered rather than typed — "member.role_changed" is an internal
// vocabulary, and a text field for it is a field that returns nothing until you
// have read the source.
func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
events, err := s.db.AuditEvents(r.Context())
if err != nil {
s.internal(w, r, "list audit events", err)
return
}
out := make([]map[string]any, 0, len(events))
for _, e := range events {
out = append(out, map[string]any{"event": e.Event, "count": e.Count})
}
writeJSON(w, http.StatusOK, map[string]any{"events": out})
}
// parseAuditCursor reads "<unix>:<id>" — the pair the keyset walk needs. Both
// halves are required: a cursor carrying only a timestamp cannot separate two rows
// written in the same second, which is the case it exists for.
func parseAuditCursor(s string) (int64, string, bool) {
at, id, found := strings.Cut(s, ":")
if !found || id == "" {
return 0, "", false
}
unix, err := strconv.ParseInt(at, 10, 64)
if err != nil || unix <= 0 {
return 0, "", false
}
return unix, id, true
}
+264
View File
@@ -0,0 +1,264 @@
package httpapi
import (
"fmt"
"net/http"
"testing"
)
// The point of the instance-wide log is that it crosses projects the caller is not
// a member of. The per-project endpoint cannot answer this question, and an
// administrator asking "what has happened on this server" was previously told to
// go and look in each project they happen to belong to.
func TestTheAuditLogCrossesProjectsTheAdminIsNotInAllOfThem(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
// A project the administrator is not a member of.
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres eget"})
theirID := theirs["id"].(string)
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "noget i deres eget", "project_id": theirID,
})
// The administrator cannot read that project's own history at all.
if resp, _ := ts.do(t, "GET", "/api/v1/projects/"+theirID+"/activity", nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("a project the admin is not in: status %d, want 404", resp.StatusCode)
}
// The instance log has it.
resp, body := ts.do(t, "GET", "/api/v1/activity", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("audit log: status %d", resp.StatusCode)
}
entries, _ := body["activity"].([]any)
var found map[string]any
for _, raw := range entries {
e := raw.(map[string]any)
if e["project_id"] == theirID && e["event"] == "task.created" {
found = e
}
}
if found == nil {
t.Fatalf("nothing from a project the admin is not in: %v", entries)
}
if found["project_name"] != "Deres eget" {
t.Errorf("project_name = %v — a row that does not name its project says nothing",
found["project_name"])
}
if found["user_name"] != "Anden" {
t.Errorf("user_name = %v", found["user_name"])
}
}
// Administrators only, and sessions only. An audit log readable with a bearer token
// would let a stolen token read the record of the theft — and of everything else on
// the instance.
func TestTheAuditLogIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
if resp, _ := other.do(t, "GET", path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
req, _ := http.NewRequest("GET", ts.URL+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 with an admin's API token: status %d, want 403", path, resp.StatusCode)
}
}
}
// The cursor is keyed on (created_at, id) because the log is written in whole
// seconds. A page boundary that landed inside a busy second would, with a
// timestamp-only cursor, either repeat those rows or skip them — and an audit log
// that skips rows is worse than no audit log.
func TestTheAuditLogPagesWithoutRepeatingOrSkipping(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Meget"})
projectID := project["id"].(string)
// Written as fast as the loop goes, so they share timestamps.
const written = 25
for i := range written {
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": fmt.Sprintf("opgave %d", i), "project_id": projectID,
})
}
seen := map[string]bool{}
cursor := ""
for page := 0; page < 20; page++ {
path := "/api/v1/activity?limit=4"
if cursor != "" {
path += "&before=" + cursor
}
resp, body := ts.do(t, "GET", path, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("page %d: status %d", page, resp.StatusCode)
}
entries, _ := body["activity"].([]any)
for _, raw := range entries {
id := raw.(map[string]any)["id"].(string)
if seen[id] {
t.Fatalf("row %s came back on two pages", id)
}
seen[id] = true
}
next, _ := body["next_cursor"].(string)
if next == "" {
break
}
if next == cursor {
t.Fatalf("the cursor did not move: %s", next)
}
cursor = next
}
// Everything that was written is in the walk exactly once: the task creations,
// plus the project creation.
if len(seen) != written+1 {
t.Errorf("the walk saw %d rows, want %d — a page boundary lost some",
len(seen), written+1)
}
}
// The filters, and the list the event filter is drawn from.
func TestTheAuditLogFiltersAndListsItsEvents(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
mineID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min", "project_id": mineID})
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
theirID := theirs["id"].(string)
other.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "deres", "project_id": theirID})
_, byUser := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID, nil)
for _, raw := range byUser["activity"].([]any) {
if got := raw.(map[string]any)["user_id"]; got != otherUser.ID {
t.Errorf("user_id filter returned a row by %v", got)
}
}
if len(byUser["activity"].([]any)) == 0 {
t.Error("the user filter returned nothing")
}
_, byProject := ts.do(t, "GET", "/api/v1/activity?project_id="+mineID, nil)
for _, raw := range byProject["activity"].([]any) {
if got := raw.(map[string]any)["project_id"]; got != mineID {
t.Errorf("project_id filter returned a row from %v", got)
}
}
_, byEvent := ts.do(t, "GET", "/api/v1/activity?event=project.created", nil)
rows := byEvent["activity"].([]any)
if len(rows) != 2 {
t.Errorf("event filter found %d project.created rows, want 2", len(rows))
}
for _, raw := range rows {
if got := raw.(map[string]any)["event"]; got != "project.created" {
t.Errorf("event filter returned %v", got)
}
}
// And the vocabulary the filter is built from.
_, events := ts.do(t, "GET", "/api/v1/activity/events", nil)
counts := map[string]float64{}
for _, raw := range events["events"].([]any) {
e := raw.(map[string]any)
counts[e["event"].(string)] = e["count"].(float64)
}
if counts["project.created"] != 2 || counts["task.created"] != 2 {
t.Errorf("event counts = %v", counts)
}
// A malformed cursor is a 400, not a silent first page: paging that quietly
// restarts is how an audit walk reads the same rows forever.
if resp, _ := ts.do(t, "GET", "/api/v1/activity?before=igår", nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("a malformed cursor: status %d, want 400", resp.StatusCode)
}
}
// A deleted account leaves its trail behind. This is the reason migration 0009
// exists: an audit log that lost its rows when somebody left would be missing
// exactly the part worth keeping.
func TestTheAuditLogOutlivesTheAccountItDescribes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
// In a project that outlives them, so the row is not carried off by the
// project cascade instead.
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
sharedID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
"email": "anden@example.dk", "role": "editor",
})
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "deres bidrag", "project_id": sharedID,
})
_, before := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID+"&event=task.created", nil)
if len(before["activity"].([]any)) != 1 {
t.Fatalf("expected one row before the delete: %v", before["activity"])
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/activity?project_id="+sharedID+"&event=task.created", nil)
rows := after["activity"].([]any)
if len(rows) != 1 {
t.Fatalf("the row went with the account: %v", rows)
}
row := rows[0].(map[string]any)
if _, ok := row["user_id"]; ok {
t.Errorf("user_id is still there after the account went: %v", row["user_id"])
}
if _, ok := row["user_name"]; ok {
t.Errorf("user_name is still there after the account went: %v", row["user_name"])
}
// And the project's own history still shows it, rather than dropping the row
// on an inner join to a user that is gone.
_, project := ts.do(t, "GET", "/api/v1/projects/"+sharedID+"/activity", nil)
var kept int
for _, raw := range project["activity"].([]any) {
if raw.(map[string]any)["event"] == "task.created" {
kept++
}
}
if kept != 1 {
t.Errorf("the project history dropped the authorless row: %d", kept)
}
}
+123 -19
View File
@@ -8,6 +8,8 @@ import (
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
@@ -25,17 +27,62 @@ type userJSON struct {
Locale string `json:"locale"`
IsAdmin bool `json:"is_admin"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
// SidebarCollapsed is which sidebar headings this person has folded away. On
// the account rather than in the browser, because folding one is a statement
// 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"`
CreatedAt string `json:"created_at"`
}
func toUserJSON(u *store.User) userJSON {
collapsed := u.SidebarCollapsed
// Never null in the response: a client that has to check for both an absent
// array and an empty one will one day check for only one of them.
if collapsed == nil {
collapsed = []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, CreatedAt: u.CreatedAt.Format(time.RFC3339),
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
// handleSetSidebarSections records which sidebar headings are folded away.
//
// Its own route rather than a field on the profile: that one backs a form with a
// save button and validates three fields together, and this writes on every click
// of a chevron. Sharing the path would mean a fold sending a name and a timezone
// it was never asked to change — and a validation error on one of those failing a
// fold.
//
// The keys are the sidebar's and are not checked here. An unknown one costs
// nothing: the sidebar folds the headings it recognises and ignores the rest, so
// a heading that is renamed or removed leaves a harmless entry rather than a
// migration.
func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request) {
var req struct {
Sections []string `json:"sections"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
// A cap, because this is a list the client decides the length of. Twenty is far
// more headings than the sidebar has ever had.
if len(req.Sections) > 20 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many sections")
return
}
user := userFrom(r.Context())
if err := s.db.SetSidebarCollapsed(r.Context(), user.ID, req.Sections); err != nil {
s.storeError(w, r, "set sidebar sections", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
@@ -88,7 +135,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
if err != nil {
s.internal(w, "create session", err)
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
@@ -130,7 +177,7 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
s.internal(w, "promote session", err)
s.internal(w, r, "promote session", err)
return
}
// The cookie is reissued so its lifetime matches the session's new one.
@@ -210,13 +257,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
s.storeError(w, "update profile", err)
s.storeError(w, r, "update profile", err)
return
}
updated, err := s.db.UserByID(r.Context(), user.ID)
if err != nil {
s.internal(w, "reload user", err)
s.internal(w, r, "reload user", err)
return
}
writeJSON(w, http.StatusOK, toUserJSON(updated))
@@ -263,7 +310,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
@@ -277,14 +324,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
"an account already exists for that email address")
return
}
s.internal(w, "create user", err)
s.internal(w, r, "create user", err)
return
}
// Accepting the invite is what adds the membership and burns the token, in one
// transaction: a signup that half-succeeded would leave a usable invite behind.
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
s.internal(w, "accept invite", err)
s.internal(w, r, "accept invite", err)
return
}
@@ -328,7 +375,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
@@ -341,7 +388,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
return
}
s.internal(w, "create first admin", err)
s.internal(w, r, "create first admin", err)
return
}
@@ -355,7 +402,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
n, err := s.db.UserCount(r.Context())
if err != nil {
s.internal(w, "count users", err)
s.internal(w, r, "count users", err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
@@ -364,7 +411,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
if err != nil {
s.internal(w, "create session", err)
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, s.cfg.SessionTTL)
@@ -393,7 +440,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if err == nil {
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
if err != nil {
s.internal(w, "create password reset", err)
s.internal(w, r, "create password reset", err)
return
}
link := s.cfg.BaseURL + "/reset?token=" + token
@@ -431,13 +478,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
// Every session ends: a password reset is what somebody does when they think
// another person has been in their account.
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
s.internal(w, "update password", err)
s.internal(w, r, "update password", err)
return
}
@@ -469,7 +516,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
keep := ""
@@ -477,7 +524,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
keep = session.ID
}
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
s.internal(w, "update password", err)
s.internal(w, r, "update password", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -520,7 +567,64 @@ func inboxName(locale string) string {
// internal logs the real error and tells the client nothing about it. The detail
// belongs in the log, where the operator can see it; in the response it would be a
// description of the server's internals handed to whoever asked.
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
//
// It is also written to the database. On a Rune the log is the container's, and
// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at
// 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)
userID := ""
if u := userFrom(r.Context()); u != nil {
userID = u.ID
}
// Best effort, and deliberately not checked: this is the path that is already
// reporting a failure, and a diagnostic that can fail the request would turn
// one broken screen into two.
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
}
+137 -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,
@@ -761,3 +763,137 @@ func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
t.Errorf("status %d, want 401", resp.StatusCode)
}
}
// last_seen_at has been written on every request since sessions existed, for
// exactly this list, and nothing read it. A session you cannot see is a session
// you cannot end.
func TestSessionsCanBeListedAndEnded(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// A second sign-in from "another device": same account, its own cookie jar.
other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("second login: status %d", resp.StatusCode)
}
resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list sessions: %d %v", resp.StatusCode, body)
}
sessions, _ := body["sessions"].([]any)
if len(sessions) != 2 {
t.Fatalf("want two sessions, got %v", sessions)
}
// Exactly one is this one, or the interface cannot tell somebody which row is
// the browser they are reading it in.
current := ""
for _, raw := range sessions {
s := raw.(map[string]any)
if s["current"] == true {
if current != "" {
t.Fatal("two sessions both claim to be the current one")
}
current = s["id"].(string)
}
if s["device"] == "" {
t.Errorf("no device summary: %v", s)
}
if s["last_seen_at"] == nil {
t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s)
}
}
if current == "" {
t.Fatal("none of the sessions is marked as the current one")
}
// End the other one.
var otherID string
for _, raw := range sessions {
if s := raw.(map[string]any); s["id"] != current {
otherID = s["id"].(string)
}
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete session: status %d", resp.StatusCode)
}
// That device is signed out, and this one is not.
if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized {
t.Errorf("the ended session still works: status %d", r.StatusCode)
}
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("ending another session logged this one out: status %d", r.StatusCode)
}
}
// Somebody else's session is not yours to end, even with its id in hand.
func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string)
if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
// And it still works.
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("somebody else ended my session: status %d", r.StatusCode)
}
}
// An API token is accepted almost everywhere, and must not be accepted here: a
// leaked token that could end its owner's sessions, or even list their devices,
// turns a theft into a lockout.
func TestSessionsAreNotReachableWithAToken(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want 403", resp.StatusCode)
}
}
func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
cases := []struct {
ua string
want string
}{
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"},
// An iPhone's user agent says Mac OS X too, so the order of the tests is
// the whole correctness of this function.
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"},
// And Edge claims to be Chrome, which claims to be Safari.
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"},
{"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"},
{"", "Ukendt enhed"},
// Not a browser at all. Its own name beats a word that says the server
// gave up: a CalDAV client is a real session.
{"curl/8.4.0", "curl/8.4.0"},
}
for _, c := range cases {
if got := describeUserAgent(c.ua); got != c.want {
t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package httpapi
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/jobs"
)
// The nightly backup, made visible.
//
// It has run since the beginning and nothing showed it. A backup nobody can see is
// a backup nobody knows has been failing — and the failure mode is quiet: a volume
// that went read-only, a disk that filled, a container that has not been up at
// midnight for a week. The first time anybody finds out is the day they need one.
//
// Administrators only, and behind a session as well as the admin check. A backup
// file is a complete copy of the database, so downloading one is downloading
// everybody's data; a leaked API token must not be able to.
type backupRunJSON struct {
ID string `json:"id"`
Started string `json:"started_at"`
// Finished is absent while a backup is still running, and on one that was
// interrupted — which is the same shape from outside and honestly so: a run
// the process died in the middle of never finished.
Finished string `json:"finished_at,omitempty"`
Size int64 `json:"size_bytes"`
Error string `json:"error,omitempty"`
// Present says the file is still on disk. Rotation keeps the most recent
// fourteen, so an older row is a true record of a backup that was made and a
// file that is no longer there — and a download link for it would 404.
Present bool `json:"present"`
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
runs, err := s.db.ListBackupRuns(r.Context(), parseLimit(r.URL.Query().Get("limit"), 30, 100))
if err != nil {
s.internal(w, r, "list backups", err)
return
}
out := make([]backupRunJSON, 0, len(runs))
for _, b := range runs {
j := backupRunJSON{
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
Size: b.SizeBytes, Error: b.Error,
}
if b.FinishedAt != nil {
j.Finished = b.FinishedAt.Format(time.RFC3339)
}
if b.Path != "" {
if _, err := os.Stat(b.Path); err == nil {
j.Present = true
}
}
out = append(out, j)
}
writeJSON(w, http.StatusOK, map[string]any{"backups": out})
}
// handleRunBackup takes one now.
//
// The same function the nightly job runs, without the "has one run today" check.
// Waiting until tonight to learn whether backups work at all is how somebody finds
// out on the day they need one — and it is the only way to get a snapshot from
// *before* something you are about to do.
//
// Synchronous. A backup of a homelab database is a second or two, and a button
// that returns immediately and tells you nothing is a button you press twice.
func (s *Server) handleRunBackup(w http.ResponseWriter, r *http.Request) {
if err := jobs.Backup(r.Context(), s.cfg, s.db, s.log); err != nil {
s.internal(w, r, "run backup", err)
return
}
runs, err := s.db.ListBackupRuns(r.Context(), 1)
if err != nil || len(runs) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
b := runs[0]
j := backupRunJSON{
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
Size: b.SizeBytes, Error: b.Error, Present: true,
}
if b.FinishedAt != nil {
j.Finished = b.FinishedAt.Format(time.RFC3339)
}
writeJSON(w, http.StatusCreated, j)
}
// handleDownloadBackup sends one backup file.
//
// The path comes from the row rather than from the request, and is then checked to
// be inside the backups directory anyway. The row is written by this process and
// should always be safe; the check is there because "should always" is how a path
// traversal gets shipped, and the thing on the other side of it is every account's
// data in one file.
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
runs, err := s.db.ListBackupRuns(r.Context(), 100)
if err != nil {
s.internal(w, r, "list backups", err)
return
}
id := chi.URLParam(r, "backupID")
var path string
for _, b := range runs {
if b.ID == id {
path = b.Path
break
}
}
if path == "" {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
dir, err := filepath.Abs(s.cfg.BackupsDir())
if err != nil {
s.internal(w, r, "backups dir", err)
return
}
full, err := filepath.Abs(path)
if err != nil || !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
file, err := os.Open(full)
if err != nil {
// Rotation keeps the most recent fourteen, so a row older than that is a
// true record with no file behind it. Not an error — a 404 is the honest
// answer to "send me that one".
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
s.internal(w, r, "stat backup", err)
return
}
// The same treatment attachments get, and for a stronger reason: this is a
// SQLite database, and nothing should ever be tempted to render it inline.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", filepath.Base(full)))
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "", info.ModTime(), file)
}
+116
View File
@@ -0,0 +1,116 @@
package httpapi
import (
"net/http"
"testing"
)
// The backup panel exists because the job was invisible, so the thing to check is
// that it can be seen and asked for — and that the file it hands out is guarded
// like what it is, which is every account's data in one download.
func TestABackupCanBeTakenListedAndDownloaded(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Nothing yet, and that is a state the list has to survive rather than a case
// nobody hits: it is what a fresh instance looks like.
_, empty := ts.do(t, "GET", "/api/v1/backups", nil)
if got, _ := empty["backups"].([]any); len(got) != 0 {
t.Fatalf("a fresh instance has %d backups", len(got))
}
resp, made := ts.do(t, "POST", "/api/v1/backups", nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("run backup: status %d, body %v", resp.StatusCode, made)
}
id, _ := made["id"].(string)
if id == "" {
t.Fatal("the new run has no id")
}
if size, _ := made["size_bytes"].(float64); size <= 0 {
t.Errorf("size_bytes = %v — a backup of a database with rows in it is not empty", made["size_bytes"])
}
if made["error"] != nil {
t.Errorf("the backup reported an error: %v", made["error"])
}
_, listed := ts.do(t, "GET", "/api/v1/backups", nil)
rows, _ := listed["backups"].([]any)
if len(rows) != 1 {
t.Fatalf("listed %d backups, want 1", len(rows))
}
row := rows[0].(map[string]any)
if row["id"] != id {
t.Errorf("the listed run is not the one that was made")
}
if row["present"] != true {
t.Error("present = false on a backup that was just written")
}
// And the file itself, which must arrive as a download rather than as
// something a browser might try to render.
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/backups/"+id, nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
dl, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer dl.Body.Close()
if dl.StatusCode != http.StatusOK {
t.Fatalf("download: status %d", dl.StatusCode)
}
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q, want application/octet-stream", ct)
}
if cd := dl.Header.Get("Content-Disposition"); cd == "" || cd[:10] != "attachment" {
t.Errorf("Content-Disposition = %q, want an attachment", cd)
}
// An id that is not a backup is a 404, not a path to try opening.
if resp, _ := ts.do(t, "GET", "/api/v1/backups/../../etc/passwd", nil); resp.StatusCode == http.StatusOK {
t.Error("a traversal in the id was served")
}
if resp, _ := ts.do(t, "GET", "/api/v1/backups/nogetsomhelst", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("an unknown id: status %d, want 404", resp.StatusCode)
}
}
// Administrators only, and sessions only. A backup is a complete copy of the
// database — a leaked token that could download one turns a stolen credential into
// a copy of everybody's data.
func TestBackupsAreAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/backups"},
{"POST", "/api/v1/backups"},
{"GET", "/api/v1/backups/whichever"},
} {
if resp, _ := other.do(t, c.method, c.path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s as an ordinary user: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/backups"},
{"POST", "/api/v1/backups"},
} {
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 admin's API token: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
}
+6 -6
View File
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil {
s.internal(w, "caldav home", err)
s.internal(w, r, "caldav home", err)
return
}
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Depth") != "0" {
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, "caldav tasks", err)
s.internal(w, r, "caldav tasks", err)
return
}
for _, t := range tasks {
@@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, "caldav report", err)
s.internal(w, r, "caldav report", err)
return
}
byID := map[string]store.Task{}
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
update.DueDatetime = parsed.DueDatetime
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
s.internal(w, "caldav update", err)
s.internal(w, r, "caldav update", err)
return
}
if parsed.Completed && !existing.Completed() {
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
task.Content = "(uden titel)"
}
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
s.internal(w, "caldav create", err)
s.internal(w, r, "caldav create", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.internal(w, "caldav delete", err)
s.internal(w, r, "caldav delete", err)
return
}
w.WriteHeader(http.StatusNoContent)
+49 -23
View File
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
comments, err := s.db.ListComments(r.Context(), taskID)
if err != nil {
s.internal(w, "list comments", err)
s.internal(w, r, "list comments", err)
return
}
out := make([]commentJSON, 0, len(comments))
@@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
if err != nil {
s.internal(w, "list attachments", err)
s.internal(w, r, "list attachments", err)
return
}
direct := make([]attachmentJSON, 0, len(files))
@@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
if err != nil {
s.internal(w, "create comment", err)
s.internal(w, r, "create comment", err)
return
}
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
@@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
if err != nil {
s.storeError(w, "update comment", err)
s.storeError(w, r, "update comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
taskID, err := s.db.CommentTask(r.Context(), commentID)
if err != nil {
s.storeError(w, "comment task", err)
s.storeError(w, r, "comment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
@@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
s.storeError(w, "delete comment", err)
s.storeError(w, r, "delete comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, "store upload", err)
s.internal(w, r, "store upload", err)
return
}
@@ -240,7 +240,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, "record attachment", err)
s.internal(w, r, "record attachment", err)
return
}
@@ -300,23 +300,55 @@ func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
return rel, size, nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
// reachAttachment answers whether this person may see an attachment at all, and
// whether they may change it.
//
// An attachment hangs on exactly one of three things, and each has its own idea of
// who may reach it. A task's is governed by its project's roles, directly or
// through the comment it hangs on. A group's is governed by nothing but ownership:
// a group belongs to one person, is never shared, and a group that is not yours is
// not found — the same rule the rest of the group routes use.
//
// Both callers below used to resolve straight to a task, so a group's attachment
// answered 404 for its own owner.
func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit bool, err error) {
user := userFrom(r.Context())
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
return false, err
}
if a.GroupID != "" {
if _, err := s.db.GetProjectGroup(r.Context(), a.GroupID, user.ID); err != nil {
return false, err
}
// Nobody else can reach it, so whoever can, can change it.
return true, nil
}
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
return
return false, err
}
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil {
return false, err
}
return role.CanEdit(), nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
if _, err := s.reachAttachment(r, attachmentID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "get attachment", err)
s.storeError(w, r, "get attachment", err)
return
}
@@ -354,22 +386,16 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
user := userFrom(r.Context())
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
canEdit, err := s.reachAttachment(r, attachmentID)
if err != nil || !canEdit {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "delete attachment", err)
s.storeError(w, r, "delete attachment", err)
return
}
+149
View File
@@ -0,0 +1,149 @@
package httpapi
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// The comment above the code list in errors.go says they are kept in one place
// "so the frontend's mapping can be checked against it rather than discovered
// from whatever a handler happened to write". Nothing checked it, and the
// frontend was quietly missing entries — which is not a cosmetic gap: a code
// with no message falls through to the server's English log prose, and a code
// mapped to the wrong sentence tells the person something untrue. Connecting
// Gmail without an OAuth client said "Det er der allerede."
var (
codeConst = regexp.MustCompile(`(?m)^\s*Code\w+\s*=\s*"([a-z_]+)"`)
messageKey = regexp.MustCompile(`(?m)^\s*([a-z_]+):\s*$|^\s*([a-z_]+):\s*'`)
messagesTop = "const MESSAGES = {"
)
func TestEveryErrorCodeHasAMessageInTheFrontend(t *testing.T) {
codes := serverErrorCodes(t)
messages := frontendMessageKeys(t)
var missing []string
for _, code := range codes {
if !messages[code] {
missing = append(missing, code)
}
}
sort.Strings(missing)
for _, code := range missing {
t.Errorf("%q is returned by the API and has no message in web/src/lib/api.js — "+
"a person would be shown the server's English log prose", code)
}
}
func serverErrorCodes(t *testing.T) []string {
t.Helper()
source, err := os.ReadFile("errors.go")
if err != nil {
t.Fatalf("read errors.go: %v", err)
}
var codes []string
for _, m := range codeConst.FindAllStringSubmatch(string(source), -1) {
codes = append(codes, m[1])
}
if len(codes) == 0 {
t.Fatal("no codes were read out of errors.go — the scanner and the file disagree")
}
return codes
}
// frontendMessageKeys reads the keys of the MESSAGES object. A small scanner
// rather than a JS parser, for the same reason the OpenAPI check uses one: it
// needs one thing out of a file this test also owns.
func frontendMessageKeys(t *testing.T) map[string]bool {
t.Helper()
source, err := os.ReadFile(filepath.Join("..", "..", "web", "src", "lib", "api.js"))
if err != nil {
t.Fatalf("read api.js: %v", err)
}
body := string(source)
start := strings.Index(body, messagesTop)
if start < 0 {
t.Fatalf("could not find %q in api.js", messagesTop)
}
body = body[start+len(messagesTop):]
end := strings.Index(body, "\n};")
if end < 0 {
t.Fatal("the MESSAGES object does not end where this scanner expects")
}
keys := map[string]bool{}
for _, line := range strings.Split(body[:end], "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
name, _, ok := strings.Cut(line, ":")
if ok {
keys[strings.TrimSpace(name)] = true
}
}
if len(keys) == 0 {
t.Fatal("no message keys were read out of api.js")
}
return keys
}
// The four that used to be indistinguishable from "that already exists".
func TestTheMisleadingConflictsHaveTheirOwnCodes(t *testing.T) {
for _, code := range []string{
CodeGmailNotConfigured,
CodeAINotConfigured,
CodeTOTPNotEnabled,
CodeInboxProtected,
} {
if code == CodeConflict {
t.Errorf("%q is still plain conflict", code)
}
}
messages := frontendMessageKeys(t)
for _, code := range []string{"gmail_not_configured", "ai_not_configured",
"totp_not_enabled", "inbox_protected"} {
if !messages[code] {
t.Errorf("%q has no Danish message", code)
}
}
}
// The Inbox is the one project that cannot be deleted, and saying "that already
// exists" about it helps nobody.
func TestDeletingTheInboxSaysWhy(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
list, _ := projects["projects"].([]any)
var inboxID string
for _, raw := range list {
p := raw.(map[string]any)
if p["is_inbox"] == true {
inboxID = p["id"].(string)
}
}
if inboxID == "" {
t.Fatal("no inbox was found")
}
resp, body := ts.do(t, "DELETE", "/api/v1/projects/"+inboxID, nil)
if resp.StatusCode != 409 {
t.Fatalf("status %d, want 409", resp.StatusCode)
}
if body["code"] != CodeInboxProtected {
t.Errorf("code = %v, want %q", body["code"], CodeInboxProtected)
}
}
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"net/http"
"time"
)
// What has gone wrong lately, kept where a restart cannot take it.
//
// The panel's watcher reports "HTTP 5xx, twice, at 11:49" — which is enough to
// know something broke and nothing at all to act on. The line that explains it is
// in the container's log, and a Rune's container is replaced on every restart, so
// by the time anybody looks the explanation is gone. These rows are the same
// information written somewhere that survives.
type serverErrorJSON struct {
ID string `json:"id"`
At string `json:"at"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
// What the handler was doing, in its own words — "list projects", "move task".
What string `json:"what"`
Message string `json:"message"`
// Who was asking, when there was somebody: a fault only one account hits is a
// different problem from one everybody hits.
UserName string `json:"user_name,omitempty"`
// RequestID ties the row back to the log line, for whoever still has it.
RequestID string `json:"request_id,omitempty"`
}
func (s *Server) handleListErrors(w http.ResponseWriter, r *http.Request) {
errs, err := s.db.ListErrors(r.Context(), parseLimit(r.URL.Query().Get("limit"), 100, 500))
if err != nil {
s.internal(w, r, "list errors", err)
return
}
out := make([]serverErrorJSON, 0, len(errs))
for _, e := range errs {
out = append(out, serverErrorJSON{
ID: e.ID, At: e.At.Format(time.RFC3339), Method: e.Method, Path: e.Path,
Status: e.Status, What: e.What, Message: e.Message,
UserName: e.UserName, RequestID: e.RequestID,
})
}
writeJSON(w, http.StatusOK, map[string]any{"errors": out})
}
+31
View File
@@ -36,8 +36,39 @@ const (
CodeRateLimited = "rate_limited"
CodeInternal = "internal_error"
CodePayloadTooLarge = "payload_too_large"
// Four situations that used to answer plain `conflict`, which the frontend
// renders as "that already exists". For these that is not merely vague, it is
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
// second factor that is off is not one that is on. A code exists so the
// interface can say the true thing — the server's own prose is English for a
// log, and deciding what a person reads is the frontend's job.
CodeGmailNotConfigured = "gmail_not_configured"
CodeAINotConfigured = "ai_not_configured"
CodeTOTPNotEnabled = "totp_not_enabled"
CodeInboxProtected = "inbox_protected"
// And a fifth, for the same reason: refusing to remove the last administrator
// 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
// stops rather than carrying on. It is never rendered anywhere.
var errLastAdmin = errors.New("httpapi: refused to remove the last administrator")
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, APIError{Code: code, Message: message})
}
+13 -13
View File
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
Limit: 2000,
})
if err != nil {
s.internal(w, "ics feed", err)
s.internal(w, r, "ics feed", err)
return
}
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil {
s.internal(w, "ics feed projects", err)
s.internal(w, r, "ics feed projects", err)
return
}
names := map[string]string{}
@@ -89,7 +89,7 @@ func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
if err != nil {
s.internal(w, "ics token", err)
s.internal(w, r, "ics token", err)
return
}
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
@@ -103,11 +103,11 @@ func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, "generate feed token", err)
s.internal(w, r, "generate feed token", err)
return
}
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
s.internal(w, "set feed token", err)
s.internal(w, r, "set feed token", err)
return
}
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
@@ -151,7 +151,7 @@ func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
reminders, err := s.db.ListReminders(r.Context(), taskID)
if err != nil {
s.internal(w, "list reminders", err)
s.internal(w, r, "list reminders", err)
return
}
out := make([]reminderJSON, 0, len(reminders))
@@ -197,7 +197,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
if err != nil {
s.internal(w, "create reminder", err)
s.internal(w, r, "create reminder", err)
return
}
@@ -211,7 +211,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, "delete reminder", err)
s.storeError(w, r, "delete reminder", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list templates", err)
s.internal(w, r, "list templates", err)
return
}
out := make([]templateJSON, 0, len(templates))
@@ -273,7 +273,7 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
if err != nil {
s.storeError(w, "save template", err)
s.storeError(w, r, "save template", err)
return
}
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
@@ -295,7 +295,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
if err != nil {
s.storeError(w, "get template", err)
s.storeError(w, r, "get template", err)
return
}
@@ -311,7 +311,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
if err != nil {
s.internal(w, "create from template", err)
s.internal(w, r, "create from template", err)
return
}
s.activity(r, project.ID, "", "project.created", map[string]any{
@@ -323,7 +323,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
if err != nil {
s.storeError(w, "delete template", err)
s.storeError(w, r, "delete template", err)
return
}
w.WriteHeader(http.StatusNoContent)
+13 -10
View File
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list filters", err)
s.internal(w, r, "list filters", err)
return
}
out := make([]filterJSON, 0, len(filters))
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
f.SortOrder = *req.SortOrder
}
if err := s.db.CreateFilter(r.Context(), f); err != nil {
s.internal(w, "create filter", err)
s.internal(w, r, "create filter", err)
return
}
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
@@ -108,7 +108,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
})
if err != nil {
s.storeError(w, "update filter", err)
s.storeError(w, r, "update filter", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -117,7 +117,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
if err != nil {
s.storeError(w, "delete filter", err)
s.storeError(w, r, "delete filter", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -129,7 +129,7 @@ func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
if err != nil {
s.storeError(w, "get filter", err)
s.storeError(w, r, "get filter", err)
return
}
s.runQuery(w, r, f.Query)
@@ -167,7 +167,7 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string)
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, "run filter", err)
s.internal(w, r, "run filter", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
@@ -210,7 +210,7 @@ type labelJSON struct {
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list labels", err)
s.internal(w, r, "list labels", err)
return
}
out := make([]labelJSON, 0, len(labels))
@@ -242,9 +242,10 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return
}
s.internal(w, "create label", err)
s.internal(w, r, "create label", err)
return
}
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
}
@@ -260,17 +261,19 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return
}
s.storeError(w, "update label", err)
s.storeError(w, r, "update label", err)
return
}
s.publishToOwner(r, "label.changed", nil)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
if err != nil {
s.storeError(w, "delete label", err)
s.storeError(w, r, "delete label", err)
return
}
s.publishToOwner(r, "label.changed", nil)
w.WriteHeader(http.StatusNoContent)
}
+267 -49
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
@@ -10,12 +11,58 @@ import (
"github.com/kristianwind/verdande/internal/store"
)
func (s *Server) gmailConfig() gmail.Config {
return gmail.Config{
// 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
// deployment decision — it is in the Rune's manifest, under version control,
// 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
}
stored, err := s.db.InstanceSettings(ctx, "gmail")
if err != nil {
// Not fatal: an unconfigured Gmail and a database that could not answer
// look the same from here, and the caller's next step is the same either
// way — say it is not set up.
return cfg
}
if id, _ := stored["client_id"].(string); cfg.ClientID == "" {
cfg.ClientID = id
}
if secret, _ := stored["client_secret"].(string); cfg.ClientSecret == "" {
cfg.ClientSecret = secret
}
return cfg
}
// gmailFromEnv says whether the deployment set the client, which the settings page
// needs so it can explain why the fields are not editable rather than accepting
// changes that will never take effect.
func (s *Server) gmailFromEnv() bool {
return s.cfg.GmailClientID != "" && s.cfg.GmailClientSecret != ""
}
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
@@ -27,23 +74,23 @@ func (s *Server) gmailConfig() gmail.Config {
// defaults.
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
cfg := s.gmailConfig()
cfg := s.gmailConfig(r.Context())
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeConflict,
"Gmail er ikke sat op på denne server — administratoren skal registrere en OAuth-klient hos Google")
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
"no Google OAuth client is registered on this server")
return
}
pkce, err := gmail.NewPKCE()
if err != nil {
s.internal(w, "gmail pkce", err)
s.internal(w, r, "gmail pkce", err)
return
}
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
settings["pkce_verifier"] = pkce.Verifier
@@ -53,7 +100,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
@@ -83,7 +130,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, "gmail settings", err)
s.internal(w, r, "gmail settings", err)
return
}
verifier, _ := settings["pkce_verifier"].(string)
@@ -102,7 +149,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
return
}
token, err := s.gmailConfig().Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
token, err := s.gmailConfig(r.Context()).Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
if err != nil {
s.log.Error("gmail exchange", "err", err, "user", user.ID)
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
@@ -118,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)
}
@@ -129,16 +179,28 @@ 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 {
s.internal(w, "save gmail tokens", err)
// 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
}
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
@@ -150,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})
@@ -163,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() {
token, err := s.gmailConfig().Refresh(ctx, refresh)
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
}
@@ -213,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 {
@@ -237,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)
@@ -270,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
}
}
@@ -300,3 +394,127 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, status)
}
// --- the OAuth client itself ------------------------------------------------------
// The registration Google requires, enterable rather than deployed.
//
// The one click that opens Google's consent screen already exists; what it needs
// behind it is a registered client, and that is not something any app can conjure:
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
// a restricted scope, so an id shipped inside a public image would be both
// extractable from it and unusable without Google's security review of every
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
// recreate the container to paste the result was not.
//
// Administrators only, and sessions only: this is the instance's identity to
// Google, and a leaked API token must not be able to read or replace it.
type gmailClientJSON struct {
ClientID string `json:"client_id"`
// Secret is written and never read back, like the AI key: a settings page that
// repopulates a password field is one that will eventually leak into a
// screenshot.
ClientSecret string `json:"client_secret,omitempty"`
HasSecret bool `json:"has_secret"`
// FromEnv says the deployment set this, in which case the stored pair is
// ignored and the fields are not worth editing. Sent so the page can say why
// rather than accepting changes that will never take effect.
FromEnv bool `json:"from_env"`
// RedirectURI is what has to be registered with Google, spelled out. It is
// derived from VERDANDE_BASE_URL and must match exactly — it is the single
// most likely thing to be wrong, and the error Google gives for it names
// neither value.
RedirectURI string `json:"redirect_uri"`
}
func (s *Server) handleGetGmailClient(w http.ResponseWriter, r *http.Request) {
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
if err != nil {
s.internal(w, r, "gmail client", err)
return
}
out := gmailClientJSON{
FromEnv: s.gmailFromEnv(),
RedirectURI: s.cfg.GmailRedirectURL(),
}
if out.FromEnv {
out.ClientID = s.cfg.GmailClientID
out.HasSecret = true
} else {
out.ClientID, _ = stored["client_id"].(string)
secret, _ := stored["client_secret"].(string)
out.HasSecret = secret != ""
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
var req gmailClientJSON
if err := decodeJSON(w, r, &req); err != nil {
return
}
if s.gmailFromEnv() {
writeError(w, http.StatusConflict, CodeConflict,
"the Gmail client is set in the environment and cannot be changed here")
return
}
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
if err != nil {
s.internal(w, r, "gmail client", err)
return
}
// An empty secret means "leave it alone", not "delete it" — otherwise
// correcting a typo in the client id would silently clear the secret.
secret, _ := stored["client_secret"].(string)
if req.ClientSecret != "" {
secret = strings.TrimSpace(req.ClientSecret)
}
id := strings.TrimSpace(req.ClientID)
if fields := map[string]string{}; id == "" && secret != "" {
fields["client_id"] = "required"
writeFieldErrors(w, fields)
return
}
if err := s.db.SetInstanceSettings(r.Context(), "gmail", map[string]any{
"client_id": id, "client_secret": secret,
}); err != nil {
s.internal(w, r, "save gmail client", err)
return
}
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(), "kristian@example.dk")
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(), "kristian@example.dk")
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
}
+8 -8
View File
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
project := todoist.FromRows(rows, name)
result, err := s.importProject(r, user, project)
if err != nil {
s.internal(w, "import project", err)
s.internal(w, r, "import project", err)
return
}
writeJSON(w, http.StatusCreated, result)
@@ -240,7 +240,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
result, err := s.importProject(r, user, project)
if err != nil {
s.internal(w, "import csv", err)
s.internal(w, r, "import csv", err)
return
}
writeJSON(w, http.StatusCreated, result)
@@ -255,12 +255,12 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
exported, err := s.buildExport(r, user, project)
if err != nil {
s.internal(w, "export project", err)
s.internal(w, r, "export project", err)
return
}
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(ctx, user.ID, true)
if err != nil {
s.internal(w, "export projects", err)
s.internal(w, r, "export projects", err)
return
}
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, "export tasks", err)
s.internal(w, r, "export tasks", err)
return
}
for _, t := range tasks {
@@ -445,14 +445,14 @@ func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request)
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, "export ics", err)
s.internal(w, r, "export ics", err)
return
}
+94 -40
View File
@@ -18,6 +18,12 @@ import (
type mailAddressResponse struct {
Address string `json:"address"`
// Configured says whether a mail server has been set up at all. Without one
// the address is still minted and still valid — the token is real, and the
// endpoint that receives mail works — but nothing on the internet routes to
// it yet, and an interface that shows the address without saying so is
// promising something that will not happen.
Configured bool `json:"configured"`
}
// handleGetMailAddress returns the personal address that turns an email into a
@@ -31,23 +37,29 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
if err != nil {
s.internal(w, "mail token", err)
s.internal(w, r, "mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
writeJSON(w, http.StatusOK, mailAddressResponse{
Address: s.mailAddress(token),
Configured: s.cfg.SMTP.Host != "",
})
}
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, "generate mail token", err)
s.internal(w, r, "generate mail token", err)
return
}
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
s.internal(w, "set mail token", err)
s.internal(w, r, "set mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
writeJSON(w, http.StatusOK, mailAddressResponse{
Address: s.mailAddress(token),
Configured: s.cfg.SMTP.Host != "",
})
}
func (s *Server) mailAddress(token string) string {
@@ -55,7 +67,15 @@ func (s *Server) mailAddress(token string) string {
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
// The mail server's own domain, which is the one that can actually route
// this — the web address may well be a tunnel hostname that receives no mail.
domain = from[strings.Index(from, "@")+1:]
//
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
// verdande@localhost, so an instance with no mail server at all was
// handing out todo+…@localhost: an address that looks real, cannot
// possibly receive anything, and gives no hint which of the two it is.
// The public hostname is at least a domain that exists.
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
domain = d
}
}
return "todo+" + token + "@" + domain
}
@@ -114,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err)
s.internal(w, r, "inbox", err)
return
}
}
@@ -125,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, "create task from mail", err)
s.internal(w, r, "create task from mail", err)
return
}
@@ -167,7 +187,7 @@ type aiSettings struct {
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
// The key is never sent back. A settings page that repopulates a password
@@ -196,7 +216,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
existing, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
// An empty key means "leave it alone", not "delete it" — otherwise saving a
@@ -211,7 +231,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
"model": req.Model, "api_key": key,
}
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
s.internal(w, "save ai settings", err)
s.internal(w, r, "save ai settings", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -246,19 +266,19 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
}
cfg, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
if !cfg.Configured() {
// Not an error the user did anything to cause: the feature is simply off.
writeError(w, http.StatusConflict, CodeConflict,
"der er ikke sat en AI-udbyder op under indstillinger")
writeError(w, http.StatusConflict, CodeAINotConfigured,
"no AI provider is configured")
return
}
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
@@ -268,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
}
@@ -279,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
Priority: task.Priority, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
s.internal(w, "create subtask", err)
s.internal(w, r, "create subtask", err)
return
}
created = append(created, toTaskJSON(*child))
@@ -295,18 +315,18 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
cfg, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, "ai settings", err)
s.internal(w, r, "ai settings", err)
return
}
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeConflict,
"der er ikke sat en AI-udbyder op under indstillinger")
writeError(w, http.StatusConflict, CodeAINotConfigured,
"no AI provider is configured")
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
if err != nil {
s.internal(w, "list tasks", err)
s.internal(w, r, "list tasks", err)
return
}
if len(tasks) == 0 {
@@ -332,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})
@@ -350,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, "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,
})
}
@@ -374,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, "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 {
s.internal(w, "save gmail settings", err)
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, "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)
}
+545
View File
@@ -771,3 +771,548 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
t.Error("the current version is hidden from members")
}
}
// --- MCP with the key in the URL --------------------------------------------------
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
// token — so this is the only shape that can be configured from it.
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
if err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
var decoded map[string]any
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
t.Fatalf("the response was not JSON-RPC: %v", err)
}
result, _ := decoded["result"].(map[string]any)
if result == nil || result["protocolVersion"] == nil {
t.Errorf("no protocol version was announced: %v", decoded)
}
}
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
// SPA fallback and answered 200 with the app shell. A connector pointed at it
// reported success and then failed to parse a page of HTML.
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
}
}
}
// A revoked key stops working, like any other.
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
var id string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
t.Fatal(err)
}
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
t.Fatal(err)
}
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
}
}
// The session cookie must NOT work here. This route sits outside the CSRF check,
// so a cookie-authenticated POST would be a cross-site request that acts as
// whoever is signed in — the exact hole the key-only rule exists to close.
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// ts.client holds the session from bootstrap. No key in the URL.
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
resp.StatusCode)
}
}
// --- mail to task ------------------------------------------------------------------
// The address used to come out as todo+…@localhost on any instance without a
// mail server, because VERDANDE_SMTP_FROM defaults to verdande@localhost and the
// domain was taken from it unconditionally. That address looks real, cannot
// receive anything, and gives no hint which of the two it is.
func TestMailAddressDoesNotClaimToBeLocalhost(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://todo.example.dk"
// Left at the default, as an instance with no mail server has it.
c.SMTP.From = "verdande@localhost"
})
ts.bootstrap(t)
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
address, _ := body["address"].(string)
if strings.HasSuffix(address, "@localhost") {
t.Errorf("address = %q — that can never receive mail", address)
}
if !strings.HasSuffix(address, "@todo.example.dk") {
t.Errorf("address = %q, want the public hostname", address)
}
if body["configured"] != false {
t.Error("no SMTP host is set, so configured should be false")
}
}
// A real sender address still wins: the mail server's own domain is the one that
// can actually route this, and the web address may be a tunnel hostname.
func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://tunnel-abc123.example.net"
c.SMTP.Host = "mail.firma.dk"
c.SMTP.From = "verdande@firma.dk"
})
ts.bootstrap(t)
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
address, _ := body["address"].(string)
if !strings.HasSuffix(address, "@firma.dk") {
t.Errorf("address = %q, want the sender's domain", address)
}
if body["configured"] != true {
t.Error("an SMTP host is set, so configured should be true")
}
}
// A 200 with the app shell is an answer that says "yes, that exists" to anything
// that asks. For /.well-known/ that is actively harmful: it is a reserved
// namespace, and a client probing it is asking a yes-or-no question.
//
// Claude asks /.well-known/oauth-authorization-server before connecting to an
// MCP server. It read the shell as "there is an authorization server here" and
// then failed trying to register with it, reporting a broken sign-in service on
// a server that has none and needs none.
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, path := range []string{
"/.well-known/oauth-authorization-server",
"/.well-known/oauth-protected-resource",
"/.well-known/openid-configuration",
"/.well-known/anything-at-all",
} {
resp, err := ts.client.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
}
if strings.Contains(string(body), "<!doctype html") {
t.Errorf("%s: answered with the app shell", path)
}
}
}
// The one well-known path that is real still works — it is a route, not a
// fallthrough, so the rule above must not have swallowed it.
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Get(ts.URL + "/.well-known/caldav")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
t.Error("CalDAV discovery was caught by the well-known rule")
}
}
// --- what the API accepts, it must actually do -------------------------------------
// project_id on an update used to be validated and then dropped: the destination
// was permission-checked, which reads as "understood", and the task never moved
// while the answer said OK. Both the REST path and the MCP tool did it.
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
taskID := task["id"].(string)
if task["project_id"] == projectID {
t.Fatal("the task started in the destination; the test proves nothing")
}
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"project_id": projectID})
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
}
if updated["project_id"] != projectID {
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
updated["project_id"], projectID)
}
// And it really is there, not merely reported as there.
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if reread["project_id"] != projectID {
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
}
}
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
projectID := project["id"].(string)
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
taskID := created["id"].(string)
moved := ts.callTool(t, "update_task", map[string]any{
"task_id": taskID, "project_id": projectID,
})
if moved["project_id"] != projectID {
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
}
}
// A project you cannot write to is refused rather than quietly ignored.
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
taskID := task["id"].(string)
mine := task["project_id"]
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"project_id": theirs["id"]})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if reread["project_id"] != mine {
t.Error("the task moved into somebody else's project")
}
}
// An unknown #project is still not an error — the thought is kept rather than
// thrown away over a typo — but it is no longer silent. It used to vanish from
// the title while the task went to the Inbox, with nothing saying why.
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Lav plakat #3Dekoration"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
}
if body["unknown_project"] != "3Dekoration" {
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
}
if body["content"] != "Lav plakat" {
t.Errorf("content = %v", body["content"])
}
}
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Lav plakat #Dekoration"})
if _, present := body["unknown_project"]; present {
t.Errorf("reported an unknown project for one that exists: %v", body)
}
}
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
note, _ := created["note"].(string)
if !strings.Contains(note, "3Dekoration") {
t.Errorf("note = %q, want it to name the project that does not exist", note)
}
}
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
// with seven children arrived as eight unrelated tasks.
func TestMCPCreateMakesSubtasks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
parent := ts.callTool(t, "create_task", map[string]any{
"content": "Runen v2", "project_id": project["id"],
})
parentID := parent["id"].(string)
child := ts.callTool(t, "create_task", map[string]any{
"content": "Tegn ikonet", "parent_id": parentID,
})
if child["parent_id"] != parentID {
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
}
// A sub-task joins its parent's project whatever else was asked for.
if child["project_id"] != project["id"] {
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
}
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
}
}
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
child := ts.callTool(t, "create_task", map[string]any{
"content": "Tegn ikonet", "parent_id": parent["id"],
})
// A tool that refuses reports it inside the result with isError, not as a
// JSON-RPC error — the call itself was well formed, the arguments were not.
response := ts.rpc(t, "tools/call", map[string]any{
"name": "create_task",
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
})
result, _ := response["result"].(map[string]any)
if result == nil || result["isError"] != true {
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
}
}
// The Gmail OAuth client, enterable rather than deployed.
//
// The registration itself cannot be avoided — Google issues no Gmail access to an
// unregistered client — but having to edit a Rune's manifest and recreate the
// container to paste the result can be, and that is what this covers.
func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, empty := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if empty["client_id"] != "" || empty["has_secret"] != false {
t.Errorf("a fresh instance already has a client: %v", empty)
}
if empty["from_env"] != false {
t.Errorf("from_env = %v with nothing in the environment", empty["from_env"])
}
// 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://localhost/oauth/gmail/callback" {
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
}
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "1234.apps.googleusercontent.com", "client_secret": "hemmelig",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("save: status %d", resp.StatusCode)
}
_, saved := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if saved["client_id"] != "1234.apps.googleusercontent.com" {
t.Errorf("client_id = %v", saved["client_id"])
}
if saved["has_secret"] != true {
t.Error("has_secret is false after saving one")
}
// Never read back. A settings page that repopulates a password field is one
// that will eventually leak into a screenshot.
if _, present := saved["client_secret"]; present {
t.Error("the secret came back in the response")
}
// Saving only the id must not clear the secret — otherwise correcting a typo
// silently breaks the connection.
ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "5678.apps.googleusercontent.com",
})
_, again := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if again["client_id"] != "5678.apps.googleusercontent.com" || again["has_secret"] != true {
t.Errorf("saving the id alone lost the secret: %v", again)
}
// And the authorise call now has something to build a URL with, which is the
// whole point: one click, and Google's consent screen.
resp, body := ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("authorize with a stored client: status %d, body %v", resp.StatusCode, body)
}
if url, _ := body["url"].(string); !strings.Contains(url, "5678.apps.googleusercontent.com") {
t.Errorf("the consent URL does not carry the stored client: %v", body["url"])
}
}
// The environment wins, and says so. A value in the Rune's manifest is applied on
// every recreate, so a form that could quietly override it would make the manifest
// a lie.
func TestTheEnvironmentsGmailClientCannotBeOverwritten(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.GmailClientID = "fra-miljøet.apps.googleusercontent.com"
c.GmailClientSecret = "også-fra-miljøet"
})
ts.bootstrap(t)
_, shown := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if shown["from_env"] != true {
t.Errorf("from_env = %v with the environment set", shown["from_env"])
}
if shown["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
t.Errorf("client_id = %v", shown["client_id"])
}
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
"client_id": "min-egen", "client_secret": "min-egen",
})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("overwriting the environment: status %d, want 409", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/gmail/client", nil)
if after["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
t.Errorf("the environment's client was replaced: %v", after["client_id"])
}
}
// Administrators only, and sessions only: this is the instance's identity to
// Google, and a leaked token must not be able to read or replace it.
func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
for _, c := range []struct {
method string
body any
}{{"GET", nil}, {"PUT", map[string]any{"client_id": "x"}}} {
if resp, _ := other.do(t, c.method, "/api/v1/gmail/client", c.body); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", c.method, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/gmail/client", 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("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
}
+263 -4
View File
@@ -3,11 +3,14 @@ package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/mcp"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
@@ -55,7 +58,8 @@ func (s *Server) buildMCP() *mcp.Server {
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
@@ -97,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
}, "task_id", "body"),
}, s.mcpAddComment)
s.registerNoteTools(m)
return m
}
@@ -124,6 +129,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
// handleMCPWithKey is the same endpoint for a client that can only be given a
// URL: `POST /mcp?key=vrd_…`.
//
// Claude's custom-connector dialog asks for a name, an address and — under
// advanced settings — an OAuth client id and secret. There is no field for a
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
// address is. The calendar feed already carries its credential in the URL for
// the same reason, and the request logger records the path without the query, so
// the key does not end up in the log.
//
// The key is the *only* thing accepted here. Reading a session cookie instead
// would make this a state-changing POST outside the CSRF check — which is to say
// a cross-site request that acts as whoever is signed in.
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key == "" || !auth.IsAPIToken(key) {
// 401 rather than 404: unlike a task, the existence of this endpoint is
// not a secret — it is written in the documentation.
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"add ?key=<api token> to the URL")
return
}
user, err := s.db.UserByAPIToken(r.Context(), key)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
s.internal(w, r, "mcp key", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
return
}
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
}
// --- tool implementations ------------------------------------------------------------
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
@@ -215,6 +256,7 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
Text string `json:"text"`
Content string `json:"content"`
ProjectID string `json:"project_id"`
ParentID string `json:"parent_id"`
DueDate string `json:"due_date"`
Priority int `json:"priority"`
Description string `json:"description"`
@@ -230,6 +272,11 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
task := &store.Task{CreatedBy: userID, Priority: 4}
labels := params.Labels
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
// title while the task went to the Inbox, with nothing anywhere saying so —
// invisible in a chat, where nobody sees the row that was written.
unknownProject := ""
if text := strings.TrimSpace(params.Text); text != "" {
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
task.Content = parsed.Content
@@ -242,6 +289,8 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
if parsed.Project != "" && params.ProjectID == "" {
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
task.ProjectID = id
} else {
unknownProject = parsed.Project
}
}
}
@@ -267,6 +316,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
if params.ProjectID != "" {
task.ProjectID = params.ProjectID
}
// A sub-task belongs to its parent's project, whatever else was asked for:
// the two cannot disagree, and the parent is the more specific instruction.
// Without this the tools could not build a tree at all — an import of a
// parent with seven children arrived as eight unrelated tasks.
if params.ParentID != "" {
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
if err != nil {
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
}
if parent.ParentID != "" {
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
}
task.ParentID = parent.ID
task.ProjectID = parent.ProjectID
}
if task.ProjectID == "" {
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
return nil, err
@@ -281,10 +347,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
}
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
return map[string]any{
out := map[string]any{
"id": task.ID, "content": task.Content, "priority": task.Priority,
"due_date": task.DueDate, "project_id": task.ProjectID,
}, nil
}
if task.ParentID != "" {
out["parent_id"] = task.ParentID
}
if unknownProject != "" {
// Phrased for a model to relay: it says what happened and what to do,
// so the person is told rather than left with a task in the wrong place.
out["note"] = fmt.Sprintf(
"There is no project called %q, so this went to the Inbox. "+
"Create the project and move it, or say which project you meant.",
unknownProject)
}
return out, nil
}
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
@@ -326,8 +405,11 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
if params.Labels != nil {
update.SetLabels, update.Labels = true, *params.Labels
}
// Moved before the field update, and through the same helper the REST handler
// uses. This used to check the caller's rights on the destination and then
// drop the argument, so the task never moved and the answer still said OK.
if params.ProjectID != nil {
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
}
}
@@ -341,9 +423,13 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
}
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
// project_id is in the answer because it is one of the things that can be
// changed here: a caller that asked for a move can see whether it happened
// rather than taking "OK" on trust.
return map[string]any{
"id": task.ID, "content": task.Content, "priority": task.Priority,
"due_date": task.DueDate, "labels": task.Labels,
"project_id": task.ProjectID,
}, nil
}
@@ -411,3 +497,176 @@ 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. #Projekt and [[note]] become links."),
"title": mcp.Str("A title. Left out, the first line of the body becomes it."),
"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."),
"title": mcp.Str("A new title."),
"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"`
Title string `json:"title"`
ProjectID string `json:"project_id"`
Pinned bool `json:"pinned"`
}
_ = json.Unmarshal(args, &params)
n := &store.Note{
CreatedBy: userID,
Title: params.Title,
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"`
Title *string `json:"title"`
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.Title != nil {
n.Title = *params.Title
}
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
}
+1 -1
View File
@@ -83,7 +83,7 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
// as everybody being logged out at once — the frontend clears its state
// and shows the sign-in screen — and nothing in the logs says why.
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
s.internal(w, "authenticate", err)
s.internal(w, r, "authenticate", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
+212
View File
@@ -0,0 +1,212 @@
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
}
found, err := s.db.LooseNotes(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)
}
type noteBody struct {
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
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.Title != nil {
n.Title = *body.Title
}
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.Title != nil {
n.Title = *body.Title
}
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, "anden@example.dk", "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, "anden@example.dk", "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)
}
}
+12 -6
View File
@@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
if err != nil {
s.internal(w, "list notifications", err)
s.internal(w, r, "list notifications", err)
return
}
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
if err != nil {
s.internal(w, "count notifications", err)
s.internal(w, r, "count notifications", err)
return
}
@@ -50,7 +50,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
if err != nil {
s.internal(w, "mark notifications read", err)
s.internal(w, r, "mark notifications read", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -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()
@@ -148,7 +154,7 @@ type pushSubscriptionRequest struct {
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
public, _, err := s.vapidKeys(r.Context())
if err != nil {
s.internal(w, "vapid keys", err)
s.internal(w, r, "vapid keys", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
@@ -172,7 +178,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
UserAgent: r.UserAgent(),
})
if err != nil {
s.internal(w, "save push subscription", err)
s.internal(w, r, "save push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -184,7 +190,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
s.internal(w, "delete push subscription", err)
s.internal(w, r, "delete push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
+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(), "kristian@example.dk")
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"] != "kristian@example.dk" {
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, "anden@example.dk", "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, "anden@example.dk", "Anden")
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
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)
}
}
+299
View File
@@ -0,0 +1,299 @@
package httpapi
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// Groups are the foldable headings over the projects in the sidebar.
//
// They sit outside /projects rather than under it: a group is not a project and
// has no members, no tasks and no roles. Every route here is scoped to the caller
// in the query itself, so there is no permission middleware to forget — a group
// that is not yours is not found.
type projectGroupJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Collapsed bool `json:"collapsed"`
SortOrder float64 `json:"sort_order"`
}
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
return projectGroupJSON{
ID: g.ID, Name: g.Name, Color: g.Color, Description: g.Description,
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
}
}
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list project groups", err)
return
}
out := make([]projectGroupJSON, 0, len(groups))
for _, g := range groups {
out = append(out, toProjectGroupJSON(g))
}
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
}
type projectGroupRequest struct {
Name *string `json:"name"`
Color *string `json:"color"`
Description *string `json:"description"`
Collapsed *bool `json:"collapsed"`
}
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
var req projectGroupRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
name := ""
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if fields := validateGroupName(name); len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
if req.Color != nil && !store.ValidColor(*req.Color) {
writeFieldErrors(w, map[string]string{"color": colorFieldError})
return
}
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
if req.Color != nil {
g.Color = *req.Color
}
if req.Description != nil {
g.Description = strings.TrimSpace(*req.Description)
}
if req.Collapsed != nil {
g.Collapsed = *req.Collapsed
}
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
s.internal(w, r, "create project group", err)
return
}
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
writeJSON(w, http.StatusCreated, toProjectGroupJSON(*g))
}
func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request) {
var req projectGroupRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
groupID := chi.URLParam(r, "groupID")
userID := userFrom(r.Context()).ID
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if fields := validateGroupName(name); len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
req.Name = &name
}
if req.Color != nil && !store.ValidColor(*req.Color) {
writeFieldErrors(w, map[string]string{"color": colorFieldError})
return
}
if req.Description != nil {
description := strings.TrimSpace(*req.Description)
if len(description) > 4000 {
writeFieldErrors(w, map[string]string{"description": "must be at most 4000 characters"})
return
}
req.Description = &description
}
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
Name: req.Name, Color: req.Color, Description: req.Description,
Collapsed: req.Collapsed,
}); err != nil {
s.storeError(w, r, "update project group", err)
return
}
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
if err != nil {
s.storeError(w, r, "get project group", err)
return
}
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
}
// groupProjectJSON is a project as the group's page lists it: the project, plus
// how much is left in it. Open tasks, not all of them — "12" beside a project
// somebody finished last year is a number that means nothing.
type groupProjectJSON struct {
projectJSON
OpenTasks int `json:"open_tasks"`
}
// handleGetProjectGroup is the group as a place rather than a heading.
//
// A heading can carry a name. A page carries what the group *is* — the sentence
// that says why these projects are one body of work, the documents that belong to
// all of them rather than to any one, and the projects themselves with what is
// left in each.
//
// The projects come with the group rather than being fetched per row, because the
// page is nothing without them and two round trips to draw one list is one too
// many.
func (s *Server) handleGetProjectGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
userID := userFrom(r.Context()).ID
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
if err != nil {
s.storeError(w, r, "get project group", err)
return
}
projects, err := s.db.ListProjects(r.Context(), userID, false)
if err != nil {
s.internal(w, r, "list projects", err)
return
}
counts, err := s.db.OpenTaskCounts(r.Context(), groupID, userID)
if err != nil {
s.internal(w, r, "open task counts", err)
return
}
inside := make([]groupProjectJSON, 0, 4)
for _, p := range projects {
if p.GroupID == groupID {
inside = append(inside, groupProjectJSON{
projectJSON: toProjectJSON(p), OpenTasks: counts[p.ID],
})
}
}
files, err := s.db.ListGroupAttachments(r.Context(), groupID)
if err != nil {
s.internal(w, r, "list group attachments", err)
return
}
attachments := make([]attachmentJSON, 0, len(files))
for _, a := range files {
attachments = append(attachments, toAttachmentJSON(a))
}
writeJSON(w, http.StatusOK, map[string]any{
"group": toProjectGroupJSON(*g), "projects": inside, "attachments": attachments,
})
}
// handleUploadGroupAttachment puts a document on the group itself.
//
// The same storage as everywhere else — content-addressed by hash, so the path
// never contains anything the uploader chose. What differs is only the parent.
func (s *Server) handleUploadGroupAttachment(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
user := userFrom(r.Context())
// Scoped by the lookup rather than by a separate check: a group that is not
// yours is not found, which is the rule for everything in this file.
if _, err := s.db.GetProjectGroup(r.Context(), groupID, user.ID); err != nil {
s.storeError(w, r, "get project group", err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeFieldErrors(w, map[string]string{"file": "required"})
return
}
defer file.Close()
if header.Size > maxUploadBytes {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, r, "store upload", err)
return
}
a := &store.Attachment{
GroupID: groupID,
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, r, "record attachment", err)
return
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
// handleDeleteProjectGroup removes the heading. The projects under it come back
// out as ungrouped, which is why this is not behind a confirmation about losing
// work: there is none to lose.
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
s.storeError(w, r, "delete project group", err)
return
}
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
w.WriteHeader(http.StatusNoContent)
}
// handleReorderProjectGroups takes the whole list, like the projects do — see
// handleReorderProjects for why.
func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
s.internal(w, r, "reorder project groups", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateGroupName(name string) map[string]string {
switch {
case name == "":
return map[string]string{"name": "required"}
case len([]rune(name)) > 200:
return map[string]string{"name": "must be 200 characters or fewer"}
}
return nil
}
+351
View File
@@ -0,0 +1,351 @@
package httpapi
import (
"net/http"
"testing"
)
func TestAGroupHoldsProjectsAndSurvivesBeingEmptied(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create group: %d %v", resp.StatusCode, group)
}
groupID := group["id"].(string)
if group["collapsed"] != false {
t.Errorf("a new group should be open: %v", group)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
resp, filed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID,
map[string]any{"group_id": groupID})
if resp.StatusCode != http.StatusOK {
t.Fatalf("file project: %d %v", resp.StatusCode, filed)
}
if filed["group_id"] != groupID {
t.Errorf("group_id = %v, want %s", filed["group_id"], groupID)
}
// The listing is what the sidebar reads, so the filing has to survive it.
if got := groupOf(t, ts, projectID); got != groupID {
t.Errorf("group_id in the list = %q, want %s", got, groupID)
}
// An empty group is still a group. This is the whole reason it is a table and
// not a string repeated on every project row.
resp, _ = ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": ""})
if resp.StatusCode != http.StatusOK {
t.Fatalf("ungroup: status %d", resp.StatusCode)
}
if got := groupOf(t, ts, projectID); got != "" {
t.Errorf("group_id = %q after ungrouping, want empty", got)
}
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
if groups, _ := listed["groups"].([]any); len(groups) != 1 {
t.Fatalf("the emptied group is gone: %v", listed)
}
}
// An omitted group_id must not clear the field: that is the difference between a
// PATCH and a PUT, and renaming a project would otherwise tip it out of its group.
func TestRenamingAProjectLeavesItInItsGroup(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
_, renamed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"name": "Bogholderi"})
if renamed["group_id"] != groupID {
t.Errorf("group_id = %v after a rename, want %s", renamed["group_id"], groupID)
}
}
// Deleting the heading is not a request to delete the work filed under it.
func TestDeletingAGroupKeepsItsProjects(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
resp, _ := ts.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete group: status %d", resp.StatusCode)
}
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
found := false
for _, raw := range projects["projects"].([]any) {
p := raw.(map[string]any)
if p["id"] != projectID {
continue
}
found = true
if p["group_id"] != nil && p["group_id"] != "" {
t.Errorf("the project still points at a group that is gone: %v", p["group_id"])
}
}
if !found {
t.Error("the project went with the group it was filed under")
}
}
// Folding is stored on the account rather than in the browser, so it has to come
// back from the server.
func TestFoldingAGroupIsRemembered(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
resp, folded := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
map[string]any{"collapsed": true})
if resp.StatusCode != http.StatusOK || folded["collapsed"] != true {
t.Fatalf("fold: %d %v", resp.StatusCode, folded)
}
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
first := listed["groups"].([]any)[0].(map[string]any)
if first["collapsed"] != true {
t.Errorf("collapsed = %v after re-reading, want true", first["collapsed"])
}
}
// Groups are per person: somebody else's is not yours to see, rename or delete,
// and a group that is not yours is not found rather than forbidden.
func TestGroupsArePerPerson(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, listed := other.do(t, "GET", "/api/v1/project-groups", nil)
if groups, _ := listed["groups"].([]any); len(groups) != 0 {
t.Errorf("somebody else's groups are visible: %v", groups)
}
if resp, _ := other.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
map[string]any{"name": "Mit"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("renaming somebody else's group: status %d, want 404", resp.StatusCode)
}
if resp, _ := other.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("deleting somebody else's group: status %d, want 404", resp.StatusCode)
}
}
// A project cannot be filed into a group that is not the caller's, even though
// both the project and the group exist and both requests would be well-formed.
func TestAProjectCannotJoinSomebodyElsesGroup(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, group := other.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Deres"})
groupID := group["id"].(string)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
projectID := project["id"].(string)
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404 — a group you cannot see is not one you can file into",
resp.StatusCode)
}
}
func TestGroupsReorderAsAWholeList(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ids := make([]string, 3)
for i, name := range []string{"En", "To", "Tre"} {
_, g := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": name})
ids[i] = g["id"].(string)
}
reversed := []string{ids[2], ids[1], ids[0]}
resp, _ := ts.do(t, "POST", "/api/v1/project-groups/reorder", map[string]any{"ids": reversed})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
got := listed["groups"].([]any)
for i, want := range reversed {
if got[i].(map[string]any)["id"] != want {
t.Fatalf("order = %v, want %v", got, reversed)
}
}
}
// A name is required, the same as a project's. An unnamed heading is a heading
// nobody can tell apart from the next one.
func TestAGroupNeedsAName(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": " "})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, want 422 — body %v", resp.StatusCode, body)
}
}
// groupOf reads a project's group back out of the listing the sidebar uses.
func groupOf(t *testing.T, ts *testServer, projectID string) string {
t.Helper()
_, body := ts.do(t, "GET", "/api/v1/projects", nil)
for _, raw := range body["projects"].([]any) {
p := raw.(map[string]any)
if p["id"] == projectID {
id, _ := p["group_id"].(string)
return id
}
}
t.Fatalf("project %s is not in the listing", projectID)
return ""
}
// Colour is a name from a closed set, on both a project and a group.
//
// Refused rather than stored: an unknown name would be written happily and then
// painted as the default, which looks like a colour that did not save.
func TestAColourMustBeOneFromThePalette(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
if group["color"] != "graphite" {
t.Errorf("a new group's colour = %v, want graphite", group["color"])
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
for _, c := range []struct {
what, method, path string
}{
{"group", "PATCH", "/api/v1/project-groups/" + groupID},
{"project", "PATCH", "/api/v1/projects/" + projectID},
} {
resp, body := ts.do(t, c.method, c.path, map[string]any{"color": "teal"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s, a real colour: %d %v", c.what, resp.StatusCode, body)
}
if body["color"] != "teal" {
t.Errorf("%s colour = %v, want teal", c.what, body["color"])
}
resp, body = ts.do(t, c.method, c.path, map[string]any{"color": "#ff0000"})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%s, a hex value: status %d, want 422 — body %v", c.what, resp.StatusCode, body)
}
}
// A rename does not disturb the colour, and a recolour does not disturb the
// name: both are PATCH fields on the same row, and the whole point of a PATCH
// is that the one you did not send is the one you did not mean.
_, renamed := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
map[string]any{"name": "Kontoret"})
if renamed["color"] != "teal" {
t.Errorf("colour = %v after a rename, want teal", renamed["color"])
}
if renamed["name"] != "Kontoret" {
t.Errorf("name = %v", renamed["name"])
}
}
// A group is somewhere you can go, not only a heading.
func TestAGroupIsAPageWithItsProjectsDescriptionAndFiles(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{
"name": "Sæsonstart", "group_id": groupID,
})
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab", "group_id": groupID})
// And one outside it, which must not appear.
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Haven"})
// Two open, one closed — the count is what is left, not what has ever been.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "skriv oplæg", "project_id": first["id"].(string),
})
_, done := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "book lokale", "project_id": first["id"].(string),
})
ts.do(t, "POST", "/api/v1/tasks/"+done["id"].(string)+"/complete", nil)
resp, page := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get group: status %d, body %v", resp.StatusCode, page)
}
projects, _ := page["projects"].([]any)
if len(projects) != 2 {
t.Fatalf("the page lists %d projects, want 2", len(projects))
}
for _, raw := range projects {
p := raw.(map[string]any)
if p["name"] == "Haven" {
t.Error("a project outside the group is on its page")
}
if p["name"] == "Sæsonstart" && p["open_tasks"] != float64(1) {
t.Errorf("open_tasks = %v, want 1 — the closed one must not count", p["open_tasks"])
}
}
// The description, which is the part a heading cannot carry.
if resp, _ := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID, map[string]any{
"description": "Alt det, der har en faktura for enden.",
}); resp.StatusCode != http.StatusOK {
t.Fatalf("set description: status %d", resp.StatusCode)
}
_, again := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
if g, _ := again["group"].(map[string]any); g["description"] != "Alt det, der har en faktura for enden." {
t.Errorf("description = %v", g["description"])
}
// And setting only the description must not lose the name or colour.
if g, _ := again["group"].(map[string]any); g["name"] != "Arbejde" {
t.Errorf("the name went with the description: %v", g["name"])
}
}
// A group belongs to one person and is never shared, so somebody else's group is
// not found — including its files.
func TestAGroupsPageAndFilesAreItsOwnersAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/project-groups/" + groupID},
{"PATCH", "/api/v1/project-groups/" + groupID},
{"POST", "/api/v1/project-groups/" + groupID + "/attachments"},
} {
resp, _ := other.do(t, c.method, c.path, map[string]any{"description": "min nu"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s %s as somebody else: status %d, want 404", c.method, c.path, resp.StatusCode)
}
}
}
+192 -27
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -22,15 +23,20 @@ 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
// the project row, so it is the owner's filing and nobody else's.
GroupID string `json:"group_id,omitempty"`
}
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,
Shared: p.MemberCount > 1,
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount,
Shared: p.MemberCount > 1, GroupID: p.GroupID,
}
}
@@ -40,7 +46,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
if err != nil {
s.internal(w, "list projects", err)
s.internal(w, r, "list projects", err)
return
}
out := make([]projectJSON, 0, len(projects))
@@ -54,7 +60,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
writeJSON(w, http.StatusOK, toProjectJSON(*p))
@@ -66,6 +72,44 @@ type projectRequest struct {
Icon *string `json:"icon"`
ViewMode *string `json:"view_mode"`
Archived *bool `json:"archived"`
// GroupID files the project under a sidebar group; "" takes it out of one.
// The empty string rather than a JSON null, because null and absent are the
// same nil pointer once decoded — and telling "put it nowhere" apart from
// "I did not mention it" is the whole point of a PATCH.
GroupID *string `json:"group_id"`
}
type reorderProjectsRequest struct {
// IDs in the order they should appear, top to bottom.
IDs []string `json:"ids"`
}
// handleReorderProjects sets the sidebar order.
//
// The whole list rather than one move: a person has a handful of projects, and
// sending the order you want is both simpler to reason about and impossible to
// get into the half-ordered state a sequence of individual moves can produce if
// one of them fails.
//
// Projects the caller does not own are skipped — sort_order is a column on the
// project, so a shared one sits where its owner put it.
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
// A ceiling rather than a guess at what is reasonable: this is a bound on
// the transaction, not an opinion about how many projects somebody may have.
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
s.internal(w, r, "reorder projects", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
@@ -79,7 +123,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if fields := validateProject(name, req.ViewMode); len(fields) > 0 {
if fields := validateProject(name, req.ViewMode, req.Color); len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
@@ -95,10 +139,23 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
p.ViewMode = *req.ViewMode
}
if err := s.db.CreateProject(r.Context(), p); err != nil {
s.internal(w, "create project", err)
s.internal(w, r, "create project", err)
return
}
// Filed under a group in the same call. `group_id` was declared on this
// request type, documented on it, applied by the update handler — and silently
// dropped here, so creating a project into a group answered 201 and put it
// nowhere. A field that is accepted and thrown away is worse than one that is
// refused.
if req.GroupID != nil && *req.GroupID != "" {
if err := s.db.SetProjectGroup(r.Context(), p.ID, *req.GroupID, user.ID); err != nil {
s.storeError(w, r, "set project group", err)
return
}
p.GroupID = *req.GroupID
}
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
s.publishToOwner(r, "project.created", toProjectJSON(*p))
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
}
@@ -114,7 +171,13 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
name = strings.TrimSpace(*req.Name)
req.Name = &name
}
if fields := validateProject(name, req.ViewMode); req.Name != nil && len(fields) > 0 {
// The name is only checked when it was sent — but the colour is checked
// whenever it was, which is why this is two conditions rather than one.
fields := validateProject(name, req.ViewMode, req.Color)
if req.Name == nil {
delete(fields, "name")
}
if len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
@@ -124,16 +187,28 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
ViewMode: req.ViewMode, Archived: req.Archived,
})
if err != nil {
s.storeError(w, "update project", err)
s.storeError(w, r, "update project", err)
return
}
// Grouping is written separately because it is scoped differently: the fields
// above belong to the project, and this one belongs to whoever is filing it.
// The route already requires ownership, so the check inside is a second lock
// on the same door rather than a different one.
if req.GroupID != nil {
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
userFrom(r.Context()).ID); err != nil {
s.storeError(w, r, "set project group", err)
return
}
}
s.activity(r, projectID, "", "project.updated", nil)
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
writeJSON(w, http.StatusOK, toProjectJSON(*p))
}
@@ -141,23 +216,56 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
writeError(w, http.StatusConflict, CodeConflict, "the Inbox cannot be deleted")
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
return
}
s.storeError(w, "delete project", err)
s.storeError(w, r, "delete project", err)
return
}
s.activity(r, projectID, "", "project.deleted", nil)
s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID})
w.WriteHeader(http.StatusNoContent)
}
type trashedProjectJSON struct {
projectJSON
DeletedAt string `json:"deleted_at"`
// PurgeAfter is when it stops being recoverable. Sent rather than left for
// the frontend to work out, because the retention window is the server's and
// a copy of it in the interface is a copy that will one day disagree.
PurgeAfter string `json:"purge_after"`
TaskCount int `json:"task_count"`
}
// handleListTrashedProjects is what makes restoring reachable. The restore
// endpoint has always existed; without this, using it meant knowing the id of
// something that had already vanished from the interface.
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list trashed projects", err)
return
}
out := make([]trashedProjectJSON, 0, len(trashed))
for _, t := range trashed {
out = append(out, trashedProjectJSON{
projectJSON: toProjectJSON(t.Project),
DeletedAt: t.DeletedAt.Format(time.RFC3339),
PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339),
TaskCount: t.TaskCount,
})
}
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
}
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
// Permission cannot be checked through ProjectRole here: a trashed project is
// deliberately invisible to it. Ownership is what governs restoring.
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, "project owner", err)
s.storeError(w, r, "project owner", err)
return
}
if owner != userFrom(r.Context()).ID {
@@ -165,13 +273,18 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
s.storeError(w, "restore project", err)
s.storeError(w, r, "restore project", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateProject(name string, viewMode *string) map[string]string {
// colorFieldError names the closed set rather than listing it. The list is ten
// long and lives in three places already; a caller that got it wrong needs to be
// pointed at the documentation, not handed a copy that will one day be stale.
const colorFieldError = "must be one of the colours in the palette — see the Color schema"
func validateProject(name string, viewMode, color *string) map[string]string {
fields := map[string]string{}
switch {
case name == "":
@@ -186,6 +299,11 @@ func validateProject(name string, viewMode *string) map[string]string {
fields["view_mode"] = "must be list, board or calendar"
}
}
// Refused rather than stored and quietly painted as the default, which is what
// an unknown name would do — and which looks like a colour that did not save.
if color != nil && !store.ValidColor(*color) {
fields["color"] = colorFieldError
}
return fields
}
@@ -201,7 +319,7 @@ type sectionJSON struct {
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
s.internal(w, "list sections", err)
s.internal(w, r, "list sections", err)
return
}
out := make([]sectionJSON, 0, len(sections))
@@ -231,7 +349,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
sec.SortOrder = *req.SortOrder
}
if err := s.db.CreateSection(r.Context(), sec); err != nil {
s.internal(w, "create section", err)
s.internal(w, r, "create section", err)
return
}
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
@@ -245,7 +363,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
sectionID := chi.URLParam(r, "sectionID")
projectID, err := s.db.SectionProject(r.Context(), sectionID)
if err != nil {
s.storeError(w, "section project", err)
s.storeError(w, r, "section project", err)
return "", "", false
}
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
@@ -276,7 +394,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
Name: req.Name, SortOrder: req.SortOrder,
}); err != nil {
s.storeError(w, "update section", err)
s.storeError(w, r, "update section", err)
return
}
s.activity(r, projectID, "", "section.updated", nil)
@@ -289,7 +407,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
s.storeError(w, "delete section", err)
s.storeError(w, r, "delete section", err)
return
}
s.activity(r, projectID, "", "section.deleted", nil)
@@ -309,7 +427,7 @@ type memberJSON struct {
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
s.internal(w, "list members", err)
s.internal(w, r, "list members", err)
return
}
out := make([]memberJSON, 0, len(members))
@@ -358,7 +476,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
if err != nil {
s.storeError(w, "get project", err)
s.storeError(w, r, "get project", err)
return
}
@@ -370,7 +488,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
s.internal(w, "add member", err)
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
@@ -380,7 +498,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, "create invite", err)
s.internal(w, r, "create invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
@@ -397,13 +515,60 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
}
type memberRoleRequest struct {
Role string `json:"role"`
}
// handleSetMemberRole changes an existing member between editor and viewer.
//
// Until now the only way to correct an invite sent with the wrong role was to
// remove the person and invite them again — which also unassigns everything they
// were responsible for, because removing a member clears their tasks' assignee.
// Fixing a dropdown should not cost somebody their work.
func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
var req memberRoleRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
role := store.Role(req.Role)
if !role.Valid() || role == store.RoleOwner {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
// The owner has no project_members row at all — ownership lives on the project
// — so this would report "not a member", which is true and completely
// unhelpful. Say the real reason instead.
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, r, "project owner", err)
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner's standing cannot be changed; ownership is transferred, not granted")
return
}
if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil {
s.storeError(w, r, "set member role", err)
return
}
s.activity(r, projectID, "", "member.role_changed",
map[string]any{"user_id": userID, "role": string(role)})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, "project owner", err)
s.storeError(w, r, "project owner", err)
return
}
if userID == owner {
@@ -412,7 +577,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
s.storeError(w, "remove member", err)
s.storeError(w, r, "remove member", err)
return
}
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
@@ -424,13 +589,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
// to learn.
func (s *Server) storeError(w http.ResponseWriter, what string, err error) {
func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) {
switch {
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
case errors.Is(err, store.ErrEmailInUse):
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
default:
s.internal(w, what, err)
s.internal(w, r, what, err)
}
}
+423
View File
@@ -0,0 +1,423 @@
package httpapi
import (
"net/http"
"testing"
)
// Restoring has been possible since the trash existed. Finding what to restore
// has not: the endpoint takes an id, and the id is exactly what you no longer
// have once the project has gone from the interface.
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
id := created["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
// Gone from the ordinary listing.
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
for _, raw := range live["projects"].([]any) {
if raw.(map[string]any)["id"] == id {
t.Fatal("a deleted project is still in the project list")
}
}
// And findable in the trash, with enough to decide whether to bring it back.
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
list, _ := trash["projects"].([]any)
if len(list) != 1 {
t.Fatalf("want one project in the trash, got %v", list)
}
entry := list[0].(map[string]any)
if entry["name"] != "Ferie" {
t.Errorf("name = %v", entry["name"])
}
if entry["task_count"] != float64(1) {
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
entry["task_count"])
}
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
t.Errorf("no deadline was reported: %v", entry)
}
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("restore: status %d", resp.StatusCode)
}
// Back, with its task.
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
t.Errorf("the task did not come back with the project: %v", got)
}
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
if list, _ := trash["projects"].([]any); len(list) != 0 {
t.Errorf("still in the trash after restoring: %v", list)
}
}
// Somebody else's deleted project is not in your trash, and you cannot restore it.
func TestTheTrashIsPerOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
id := created["id"].(string)
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
if list, _ := trash["projects"].([]any); len(list) != 0 {
t.Errorf("somebody else's deleted project is visible: %v", list)
}
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var ids []string
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
ids = append(ids, body["id"].(string))
}
// Back to front.
reversed := []string{ids[2], ids[1], ids[0]}
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
projects, _ := list["projects"].([]any)
var got []string
for _, raw := range projects {
p := raw.(map[string]any)
// The Inbox sorts ahead of everything by its own rule; it is not part of
// what was reordered.
if p["is_inbox"] == true {
continue
}
got = append(got, p["id"].(string))
}
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
t.Errorf("order = %v, want %v", got, reversed)
}
}
// sort_order is a column on the project, not a per-viewer preference, so
// reordering must not rearrange a project somebody else owns.
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
theirID := theirs["id"].(string)
_, before := other.do(t, "GET", "/api/v1/projects", nil)
var wanted float64
for _, raw := range before["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == theirID {
wanted = p["sort_order"].(float64)
}
}
// Ask, as somebody else, to put their project first.
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
"ids": []string{theirID},
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, after := other.do(t, "GET", "/api/v1/projects", nil)
for _, raw := range after["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == theirID {
if p["sort_order"] != wanted {
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
wanted, p["sort_order"])
}
}
}
}
// The member list errored on every project — the ORDER BY named an expression,
// which SQLite refuses across a UNION — so the share panel had never shown
// anybody, not even the owner.
func TestListMembersWorksAtAll(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
members, _ := body["members"].([]any)
if len(members) != 1 {
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
}
if m := members[0].(map[string]any); m["role"] != "owner" {
t.Errorf("role = %v, want owner", m["role"])
}
}
// And the Inbox, which is the one every task drawer asks about.
func TestListMembersWorksForTheInbox(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
var inboxID string
for _, raw := range list["projects"].([]any) {
if p := raw.(map[string]any); p["is_inbox"] == true {
inboxID = p["id"].(string)
}
}
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
}
// A role sent by mistake used to be uncorrectable: the only way back was to remove
// the person and invite them again, which unassigns every task they were
// responsible for. Fixing a dropdown should not cost somebody their work.
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "anden@example.dk", "Anden")
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anden@example.dk", "role": "viewer",
})
// A viewer cannot write. That is the rule, not the bug.
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
map[string]any{"content": "prøv", "project_id": projectID}); resp.StatusCode != http.StatusNotFound {
t.Fatalf("a viewer created a task: status %d", resp.StatusCode)
}
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "editor"})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("promote to editor: status %d", resp.StatusCode)
}
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
map[string]any{"content": "nu kan jeg", "project_id": projectID}); resp.StatusCode != http.StatusCreated {
t.Errorf("an editor could not create a task: status %d", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
for _, raw := range members["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == memberUser.ID && m["role"] != "editor" {
t.Errorf("role = %v after the change, want editor", m["role"])
}
}
}
// The owner is not a member row, and ownership is not a role you can hand out.
func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "anden@example.dk", "Anden")
owner, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anden@example.dk", "role": "editor",
})
// Demoting the owner would leave a project nobody can administer.
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+owner.ID,
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("changing the owner's role: status %d, want 409", resp.StatusCode)
}
// And no member may promote themselves to owner.
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "owner"}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("granting ownership: status %d, want 422", resp.StatusCode)
}
// Nor may an editor change anybody's role: this is the owner's to do.
if resp, _ := member.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("an editor changed a role: status %d, want 404", resp.StatusCode)
}
// Somebody with no membership at all is not quietly given one.
stranger := ts.newUser(t, "tredje@example.dk", "Tredje")
_ = stranger
strangerUser, _ := ts.db.UserByEmail(t.Context(), "tredje@example.dk")
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+strangerUser.ID,
map[string]any{"role": "editor"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
}
}
// A project can have more than one section.
//
// Reported from use: sections "have no function, because you cannot create more
// than one". Every test here made exactly one, which is the shape of test that
// cannot see this — and so is every smoke test.
func TestAProjectCanHaveSeveralSections(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
id := project["id"].(string)
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
for _, name := range names {
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections",
map[string]any{"name": name})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create section %q: status %d, body %v", name, resp.StatusCode, body)
}
}
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
got, _ := listed["sections"].([]any)
if len(got) != len(names) {
t.Fatalf("the project has %d sections, want %d", len(got), len(names))
}
// In the order they were made, and each with its own place: a sort_order that
// came out the same for all three would put them in an arbitrary order that
// changes between reads.
seen := map[float64]bool{}
for i, raw := range got {
s := raw.(map[string]any)
if s["name"] != names[i] {
t.Errorf("section %d is %v, want %q", i, s["name"], names[i])
}
order, _ := s["sort_order"].(float64)
if seen[order] {
t.Errorf("two sections share sort_order %v", order)
}
seen[order] = true
}
}
// A project can be created straight into a group.
//
// `group_id` was declared on the request type, documented on it, and applied by
// the update handler — but the create handler ignored it, so a client that asked
// for a project in a group got a 201 and a project in no group. A field that is
// accepted and thrown away is worse than one that is refused: nothing reports it.
func TestAProjectCanBeCreatedIntoAGroup(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
resp, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{
"name": "Sæsonstart", "group_id": groupID,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: status %d, body %v", resp.StatusCode, project)
}
if project["group_id"] != groupID {
t.Errorf("the response says group_id = %v, want %q", project["group_id"], groupID)
}
// And it is really filed there, not just claimed in the reply.
_, listed := ts.do(t, "GET", "/api/v1/projects", nil)
var found map[string]any
for _, raw := range listed["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == project["id"] {
found = p
}
}
if found == nil {
t.Fatal("the new project is not in the list")
}
if found["group_id"] != groupID {
t.Errorf("the stored project has group_id = %v, want %q", found["group_id"], groupID)
}
}
// A section holds as many tasks as you put in it.
//
// Reported from use: "sections can only have one task". Driven through the same
// endpoint the interface uses, with the same `after_id` the drop handler sends —
// the second task goes after the first, which is the case that would fail if
// positioning inside a section were wrong.
func TestASectionHoldsMoreThanOneTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Skovvænget"})
projectID := project["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
map[string]any{"name": "Håndværker"})
sectionID := section["id"].(string)
var previous string
for _, content := range []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"} {
_, created := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": content, "project_id": projectID,
})
id := created["id"].(string)
resp, moved := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
"project_id": projectID, "section_id": sectionID, "after_id": previous,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("move %q: status %d, body %v", content, resp.StatusCode, moved)
}
if moved["section_id"] != sectionID {
t.Fatalf("%q did not land in the section: %v", content, moved["section_id"])
}
previous = id
}
_, listed := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
tasks, _ := listed["tasks"].([]any)
inSection := []string{}
for _, raw := range tasks {
task := raw.(map[string]any)
if task["section_id"] == sectionID {
inSection = append(inSection, task["content"].(string))
}
}
if len(inSection) != 3 {
t.Fatalf("the section holds %d tasks, want 3: %v", len(inSection), inSection)
}
// And in the order they were placed, since each went after the one before it.
want := []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"}
for i, content := range want {
if inSection[i] != content {
t.Errorf("position %d is %q, want %q — the whole list: %v", i, inSection[i], content, inSection)
}
}
}
+13
View File
@@ -28,6 +28,19 @@ func (s *Server) publish(projectID, event string, payload any) {
s.hub.Publish(projectID, event, payload)
}
// publishToOwner sends a change that belongs to a person rather than to a
// project: their projects, their labels, their saved filters.
//
// Project membership is what the socket subscribes to, and a project that has
// just been created has no subscribers — the person is not watching a thing that
// did not exist a moment ago. So these go to the user instead, which also
// reaches their other devices.
func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
if user := userFrom(r.Context()); user != nil {
s.hub.PublishToUser(user.ID, event, payload)
}
}
// handleWebSocket is the live-sync connection.
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
+170 -4
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)
@@ -90,6 +115,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// authenticated by the token in the recipient address.
r.Post("/inbound/mail", s.handleInboundMail)
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
// it at all. Same reasoning as the calendar feed above: a client that cannot
// be told to send a header means the token in the URL is the credential.
//
// Outside /api/v1 deliberately, because it must not be reachable with a
// session cookie. A cookie-authenticated POST with no CSRF check is a
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
// nothing else.
r.Post("/mcp", s.handleMCPWithKey)
// Google sends the browser here after consent. Outside /api/v1 because it is a
// top-level navigation rather than a fetch, but still behind the session — the
// tokens it exchanges belong to whoever is signed in.
@@ -120,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) {
@@ -132,6 +177,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireAuth)
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Post("/totp/setup", s.handleTOTPSetup)
@@ -139,6 +185,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/totp/disable", s.handleTOTPDisable)
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
// Behind requireSession, like the API tokens and for the same
// reason: a leaked token must not be able to end the sessions of
// the person it was stolen from, or list their devices.
r.Group(func(r chi.Router) {
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)
})
})
})
@@ -209,12 +273,45 @@ 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)
r.Delete("/", s.handleDisconnectGmail)
r.Post("/authorize", s.handleGmailAuthorize)
r.Post("/sync", s.handleGmailSyncNow)
// The OAuth client itself is the instance's registration with
// Google, not anybody's mailbox — so unlike everything above it, it
// is administrators only, and behind a session as well: a leaked
// token must not be able to read or replace this instance's
// identity to Google.
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/client", s.handleGetGmailClient)
r.Put("/client", s.handleSetGmailClient)
})
})
r.Route("/import", func(r chi.Router) {
@@ -242,11 +339,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/{labelID}", s.handleDeleteLabel)
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
r.Get("/people", s.handlePeople)
r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) {
r.Get("/", s.handleListProjects)
r.Post("/", s.handleCreateProject)
// Above /{projectID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjects)
r.Route("/{projectID}", func(r chi.Router) {
// Read access is the floor for the whole subtree; the writes
@@ -268,14 +369,60 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
// Restoring is outside the block above: a trashed project is invisible
// to the permission check by design, so the handler resolves ownership
// itself.
// The instance's membership. Administrators only, and behind
// requireSession as well as requireAdmin: an API token belongs to a
// person, and a leaked one must not be able to mint an account or
// delete somebody else's.
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.
r.Get("/backups", s.handleListBackups)
r.Post("/backups", s.handleRunBackup)
r.Get("/backups/{backupID}", s.handleDownloadBackup)
// The instance-wide history. Above the per-project one in every
// sense: it crosses projects the caller is not a member of, which
// is the whole reason it is here and not there.
r.Get("/activity", s.handleAuditLog)
r.Get("/activity/events", s.handleAuditEvents)
r.Get("/users", s.handleListUsers)
r.Post("/users", s.handleCreateUser)
r.Patch("/users/{userID}", s.handleUpdateUser)
r.Delete("/users/{userID}", s.handleDeleteUser)
r.Delete("/invites/{inviteID}", s.handleDeleteInvite)
})
// Groups are a person's own headings over their projects, so there is
// no project permission to check — every query here is scoped to the
// caller.
r.Route("/project-groups", func(r chi.Router) {
r.Get("/", s.handleListProjectGroups)
r.Post("/", s.handleCreateProjectGroup)
// Above /{groupID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjectGroups)
r.Get("/{groupID}", s.handleGetProjectGroup)
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
r.Post("/{groupID}/attachments", s.handleUploadGroupAttachment)
})
// The trash is outside the block above: a deleted project is invisible
// to the permission check by design, so these handlers resolve
// ownership themselves.
r.Get("/trash/projects", s.handleListTrashedProjects)
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
r.Route("/sections/{sectionID}", func(r chi.Router) {
@@ -336,7 +483,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
// The version travels with the health check because this is the one endpoint
// that answers without a session. "Which version is actually running?" is
// otherwise a question you cannot ask from outside — and after a deploy it is
// the first question there is.
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": Version})
}
// serveWeb serves the PWA, falling back to index.html so that client-side routes
@@ -348,6 +499,21 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
// Handing back the shell there does not merely waste a response: it tells
// whoever asked that the thing they were looking for exists.
//
// That is not hypothetical. Claude probes
// /.well-known/oauth-authorization-server before connecting to an MCP server,
// read 200 and a page of HTML as "there is an authorization server here", and
// then failed trying to register with it — reporting a broken sign-in service
// on a server that has none and needs none. A 404 means "no OAuth here", the
// client stops asking, and the key in the URL is used as intended.
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
if s.web == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
+147
View File
@@ -0,0 +1,147 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// Where a person is signed in, and how to end one of them.
//
// `last_seen_at` has been written on every request since sessions existed —
// throttled to once a minute, precisely so this list could say "this device, 2
// minutes ago" — and nothing has ever read it. A session you cannot see is a
// session you cannot end.
type sessionJSON struct {
ID string `json:"id"`
// Current marks the one making the request, so the interface can say "denne
// enhed" rather than making somebody work it out from a user agent.
Current bool `json:"current"`
// Device is a summary; UserAgent is what it was summarised from. Both, because
// the summary is what you read and the original is what settles the question
// when the summary is not enough.
Device string `json:"device"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at"`
ExpiresAt string `json:"expires_at"`
}
// handleListSessions lists the caller's live sessions, most recently used first.
//
// The id here is the stored hash of the cookie, never the cookie itself: it cannot
// be pasted into a browser, and it is what the delete below needs.
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
current := sessionFrom(r.Context())
sessions, err := s.db.ListSessions(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list sessions", err)
return
}
out := make([]sessionJSON, 0, len(sessions))
for _, sess := range sessions {
out = append(out, sessionJSON{
ID: sess.ID,
Current: current != nil && sess.ID == current.ID,
Device: describeUserAgent(sess.UA),
UserAgent: sess.UA,
IP: sess.IP,
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
ExpiresAt: sess.ExpiresAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
}
// handleDeleteSession signs one device out. Ending the current one is allowed and
// is simply logging out — refusing it would be a rule with no reason behind it,
// and somebody clearing every device wants this one gone too.
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
sessionID := chi.URLParam(r, "sessionID")
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
s.storeError(w, r, "delete session", err)
return
}
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
s.clearSessionCookie(w)
}
w.WriteHeader(http.StatusNoContent)
}
// describeUserAgent turns a user-agent string into something a person recognises.
//
// Deliberately a handful of substring tests rather than a parsing library. The
// question this answers is "is one of these me?", and for that a wrong guess
// between two Chromium forks costs nothing while a dependency that has to be kept
// current costs something every year. The raw string is sent alongside, so
// anything this gets wrong is still visible.
//
// Order matters: every browser's user agent claims to be several other browsers,
// so the most specific name has to be tested first.
func describeUserAgent(ua string) string {
if strings.TrimSpace(ua) == "" {
return "Ukendt enhed"
}
browser := ""
switch {
case strings.Contains(ua, "Edg/"):
browser = "Edge"
case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"):
browser = "Opera"
case strings.Contains(ua, "Firefox/"):
browser = "Firefox"
case strings.Contains(ua, "Chrome/"):
browser = "Chrome"
case strings.Contains(ua, "Safari/"):
browser = "Safari"
}
system := ""
switch {
// Before "Mac OS X": an iPhone's user agent says both.
case strings.Contains(ua, "iPhone"):
system = "iPhone"
case strings.Contains(ua, "iPad"):
system = "iPad"
case strings.Contains(ua, "Android"):
system = "Android"
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
system = "macOS"
case strings.Contains(ua, "Windows"):
system = "Windows"
case strings.Contains(ua, "Linux"):
system = "Linux"
}
switch {
case browser != "" && system != "":
return browser + " på " + system
case browser != "":
return browser
case system != "":
return system
}
// Nothing recognised: show the string itself rather than "Ukendt". A CalDAV
// client or a script is a real session, and its own name is more useful than a
// word that says the server gave up.
return truncateRunes(ua, 60)
}
func truncateRunes(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "…"
}
+124 -25
View File
@@ -1,6 +1,8 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
@@ -31,9 +33,14 @@ type taskJSON struct {
Completed bool `json:"completed"`
CompletedAt string `json:"completed_at,omitempty"`
CreatedBy string `json:"created_by"`
SortOrder float64 `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// What the row can say without being opened. Sent with every task rather than
// fetched when one is opened, because the point is that you see it in the list.
SubtaskCount int `json:"subtask_count,omitempty"`
SubtaskDone int `json:"subtask_done,omitempty"`
AttachmentCount int `json:"attachment_count,omitempty"`
SortOrder float64 `json:"sort_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toTaskJSON(t store.Task) taskJSON {
@@ -43,9 +50,11 @@ func toTaskJSON(t store.Task) taskJSON {
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
RepeatText: recurrence.Describe(t.RecurrenceRule),
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
SortOrder: t.SortOrder,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
SubtaskCount: t.SubtaskCount, SubtaskDone: t.SubtaskDone,
AttachmentCount: t.AttachmentCount,
SortOrder: t.SortOrder,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
// Never nil: a client iterating labels should not have to null-check first.
Labels: t.Labels,
}
@@ -97,7 +106,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
if err != nil {
s.internal(w, "list tasks", err)
s.internal(w, r, "list tasks", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
@@ -114,7 +123,7 @@ func toTaskList(tasks []store.Task) []taskJSON {
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
writeJSON(w, http.StatusOK, toTaskJSON(*t))
@@ -160,7 +169,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
if projectID == "" {
var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err)
s.internal(w, r, "inbox", err)
return
}
}
@@ -224,7 +233,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
s.internal(w, "create task", err)
s.internal(w, r, "create task", err)
return
}
// The store wrote the labels; the struct it was handed does not know that, and
@@ -249,6 +258,16 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
return
}
// A change of project is a move, not a field: it has to renumber the task
// among its new neighbours. Done before the rest so a request that both moves
// and edits ends up entirely applied or entirely refused.
if req.ProjectID != nil {
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
return
}
}
u := store.TaskUpdate{
Description: req.Description, Priority: req.Priority,
SectionID: req.SectionID, ParentID: req.ParentID,
@@ -290,13 +309,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
s.storeError(w, "update task", err)
s.storeError(w, r, "update task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
@@ -315,13 +334,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
}
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "complete task", err)
s.storeError(w, r, "complete task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
@@ -357,13 +376,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
s.storeError(w, "reopen task", err)
s.storeError(w, r, "reopen task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
@@ -382,11 +401,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.storeError(w, "delete task", err)
s.storeError(w, r, "delete task", err)
return
}
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
@@ -415,7 +434,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
@@ -437,12 +456,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
}
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
s.storeError(w, "move task", err)
s.storeError(w, r, "move task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, "get task", err)
s.storeError(w, r, "get task", err)
return
}
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
@@ -456,6 +475,10 @@ type quickAddRequest struct {
// ProjectID is the view the user was looking at. A "#project" in the text
// wins over it: what somebody typed beats where they happened to be standing.
ProjectID string `json:"project_id,omitempty"`
// SectionID is the section the box was opened in, for the field that sits at
// the foot of one. Same rule as the project: a "/section" in the text wins,
// because typing it is a choice and standing there is a circumstance.
SectionID string `json:"section_id,omitempty"`
}
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
@@ -473,17 +496,24 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
}
projectID := req.ProjectID
// Set when the text named a project that does not exist. The task is still
// created — see below — but the caller is told, because the alternative is
// what it used to be: "#Dekoration" disappears from the title, the task lands
// somewhere else, and nothing anywhere says why.
unknownProject := ""
if parsed.Project != "" {
// An unknown "#name" is not an error: the task still gets created, in the
// Inbox, rather than a thought being thrown away over a typo.
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
} else {
unknownProject = parsed.Project
}
}
if projectID == "" {
var err error
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, "inbox", err)
s.internal(w, r, "inbox", err)
return
}
}
@@ -492,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
return
}
// Resolved after the project, because a section belongs to one project and the
// same name can exist in several. A name that matches nothing is reported the
// same way an unknown project is: the task is still created, in the project
// without a section, and the caller is told which word did not land.
sectionID, unknownSection := "", ""
// The section the box belongs to, unless the text names one. Checked against
// the project the task actually landed in, so a "#project" that moved it
// elsewhere cannot drag a section id from another project along with it.
if req.SectionID != "" {
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
sectionID = req.SectionID
}
}
if parsed.Section != "" {
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
sectionID = id
} else {
unknownSection = parsed.Section
}
}
t := &store.Task{
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence,
}
if parsed.DueDate != "" {
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
@@ -504,12 +556,23 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
s.internal(w, "create task", err)
s.internal(w, r, "create task", err)
return
}
t.Labels = parsed.Labels
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
if unknownProject != "" || unknownSection != "" {
// Alongside the task rather than instead of it: the task was created, and
// this says what could not be honoured while creating it.
writeJSON(w, http.StatusCreated, struct {
taskJSON
UnknownProject string `json:"unknown_project,omitempty"`
UnknownSection string `json:"unknown_section,omitempty"`
}{toTaskJSON(*t), unknownProject, unknownSection})
return
}
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
}
@@ -525,6 +588,42 @@ func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
// --- helpers --------------------------------------------------------------------
// moveTaskToProject moves a task to another project, if that is really what was
// asked for.
//
// Shared by the REST handler and the MCP tool, because they had drifted: both
// accepted project_id on an update, both checked the caller's rights on the
// destination — which reads as "understood" — and then neither passed it on,
// because store.TaskUpdate has no such field. The task stayed where it was and
// the response said OK. An argument that is validated and then dropped is worse
// than one that is refused.
//
// A move rather than a field assignment: sort_order is per project, so landing
// in a new one means being given a position among its tasks.
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
if projectID == "" {
return nil
}
current, err := s.db.GetTask(ctx, taskID, userID)
if err != nil {
return errors.New("not found")
}
if current.ProjectID == projectID {
return nil // already there; not worth a write or a renumber
}
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
return errors.New("not found")
}
// No neighbours: it lands at the start of the destination, which is the only
// answer available when the caller did not say where. Section is cleared —
// a section belongs to the project being left behind.
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
return errors.New("not found")
}
return nil
}
func validateTaskContent(content string, priority *int) map[string]string {
fields := map[string]string{}
switch {
+95
View File
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
t.Errorf("unread = %v after marking everything read", after["unread"])
}
}
// Quick add can put a task straight into a section.
//
// A section belongs to one project, so the name is resolved after the project is
// decided — and two projects may both have a "Produktion", which are different
// sections. A name that matches nothing is reported the way an unknown project is:
// the task is still created, and the caller is told which word did not land.
func TestQuickAddPutsATaskInASection(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
projectID := project["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
map[string]any{"name": "Kunder/Ordrer"})
sectionID := section["id"].(string)
// The section name has a slash in it, which is also the sigil. The bare form is
// greedy, so the whole name comes across.
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
}
if task["section_id"] != sectionID {
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
}
if task["content"] != "Karpenhøj kaffe bestilling" {
t.Errorf("content = %v — the sigils should be gone", task["content"])
}
// Case-insensitive, like the project sigil.
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
if lower["section_id"] != sectionID {
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
}
// A name that matches nothing still creates the task, and says so.
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
if missing["section_id"] != nil && missing["section_id"] != "" {
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
}
if missing["unknown_section"] != "Findesikke" {
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
}
if missing["content"] != "noget andet" {
t.Errorf("content = %v", missing["content"])
}
// A section in another project is a different section, and must not be found.
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
map[string]any{"name": "Egen"})
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "forkert sted #Risteriet /Egen"})
if crossed["unknown_section"] != "Egen" {
t.Errorf("a section from another project was found: %v", crossed["section_id"])
}
}
// The field at the foot of a section passes the section it sits in, and a
// "#project" in the same line can move the task out from under it.
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
hereID := here["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
map[string]any{"name": "Produktion"})
sectionID := section["id"].(string)
// Standing in the section is enough; no sigil needed.
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
})
if plain["section_id"] != sectionID {
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
}
// A "#project" that moves the task must not drag the section id with it: that
// section belongs to the project being left behind.
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
})
if moved["section_id"] != nil && moved["section_id"] != "" {
t.Errorf("the section came along to another project: %v", moved["section_id"])
}
if moved["content"] != "male gavlen" {
t.Errorf("content = %v", moved["content"])
}
}
+3 -3
View File
@@ -36,7 +36,7 @@ type createdTokenJSON struct {
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list api tokens", err)
s.internal(w, r, "list api tokens", err)
return
}
@@ -96,7 +96,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
if err != nil {
s.internal(w, "create api token", err)
s.internal(w, r, "create api token", err)
return
}
@@ -117,7 +117,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
if err != nil {
s.storeError(w, "delete api token", err)
s.storeError(w, r, "delete api token", err)
return
}
w.WriteHeader(http.StatusNoContent)
+10 -10
View File
@@ -29,12 +29,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
if err != nil {
s.internal(w, "generate totp secret", err)
s.internal(w, r, "generate totp secret", err)
return
}
// Stored but not enabled: confirm decides that.
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
s.internal(w, "store totp secret", err)
s.internal(w, r, "store totp secret", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
@@ -63,15 +63,15 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
codes, hashes, err := auth.NewRecoveryCodes()
if err != nil {
s.internal(w, "generate recovery codes", err)
s.internal(w, r, "generate recovery codes", err)
return
}
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
s.internal(w, "store recovery codes", err)
s.internal(w, r, "store recovery codes", err)
return
}
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
s.internal(w, "enable totp", err)
s.internal(w, r, "enable totp", err)
return
}
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
s.internal(w, "disable totp", err)
s.internal(w, r, "disable totp", err)
return
}
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
@@ -116,7 +116,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
user := userFrom(r.Context())
if !user.TOTPEnabled {
writeError(w, http.StatusConflict, CodeConflict,
writeError(w, http.StatusConflict, CodeTOTPNotEnabled,
"two-factor authentication is not switched on")
return
}
@@ -127,11 +127,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
codes, hashes, err := auth.NewRecoveryCodes()
if err != nil {
s.internal(w, "generate recovery codes", err)
s.internal(w, r, "generate recovery codes", err)
return
}
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
s.internal(w, "store recovery codes", err)
s.internal(w, r, "store recovery codes", err)
return
}
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
@@ -140,7 +140,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "count recovery codes", err)
s.internal(w, r, "count recovery codes", err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
+240
View File
@@ -0,0 +1,240 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// Who is on the instance, and how somebody else gets on it.
//
// There is no open registration and there never was: an account comes into
// existence in exactly two ways — the first admin at setup, and somebody accepting
// an invite. This surface is the second one made reachable without first sharing a
// project, which is what "create a user" turns out to mean here.
//
// The whole file is administrators only.
type adminUserJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
IsAdmin bool `json:"is_admin"`
// Self marks the caller, so the interface can grey out the two things nobody
// should do to their own account from here.
Self bool `json:"self"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at,omitempty"`
// ProjectCount and TaskCount are what a delete would destroy, and
// AuthoredElsewhere is what it would leave behind without an author. Sent with
// the list rather than fetched when the button is pressed, so the confirmation
// can name real numbers without a round trip in the middle of a decision.
ProjectCount int `json:"project_count"`
TaskCount int `json:"task_count"`
AuthoredElsewhere int `json:"authored_elsewhere"`
}
type pendingInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
// ProjectName is empty for an invite to the instance itself, which is what
// this page issues. The two read very differently to whoever is looking.
ProjectName string `json:"project_name,omitempty"`
Role string `json:"role"`
InvitedBy string `json:"invited_by,omitempty"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
me := userFrom(r.Context())
users, err := s.db.ListUsers(r.Context())
if err != nil {
s.internal(w, r, "list users", err)
return
}
invites, err := s.db.ListPendingInvites(r.Context())
if err != nil {
s.internal(w, r, "list pending invites", err)
return
}
out := make([]adminUserJSON, 0, len(users))
for _, u := range users {
j := adminUserJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
AuthoredElsewhere: u.AuthoredElsewhere,
}
// Zero means never signed in, which is not a time and must not be sent as
// one — 1970 in an interface reads as a bug rather than as "never".
if !u.LastSeenAt.IsZero() {
j.LastSeenAt = u.LastSeenAt.Format(time.RFC3339)
}
out = append(out, j)
}
pending := make([]pendingInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, pendingInviteJSON{
ID: i.ID, Email: i.Email, ProjectName: i.ProjectName, Role: string(i.Role),
InvitedBy: i.InvitedBy,
CreatedAt: i.CreatedAt.Format(time.RFC3339),
ExpiresAt: i.ExpiresAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"users": out, "invites": pending})
}
type createUserRequest struct {
Email string `json:"email"`
}
// handleCreateUser invites somebody to the instance.
//
// An invite rather than an account with a password the administrator chooses. A
// password typed here would be known to two people before it was ever used, and
// would have to travel to the second one by some channel nobody has thought about.
// The link does the same job and ends with a password only its owner has seen.
//
// No name is asked for, because the signup form asks the person themselves — a
// name an administrator guesses is a name somebody has to correct.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
inviter := userFrom(r.Context())
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
if _, err := s.db.UserByEmail(r.Context(), email); err == nil {
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
return
}
// RoleEditor is carried on the row because the column requires a role, and it
// is never read: AcceptInvite returns early when there is no project to grant
// anything on.
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
inviter.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, r, "create instance invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
emailed := false
if s.mail.Configured() {
if err := s.mail.SendInvite(r.Context(), email, inviter.Name, "", link, s.cfg.InviteTTL); err != nil {
s.log.Error("send instance invite", "err", err, "to", email)
} else {
emailed = true
}
}
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
}
type updateUserRequest struct {
IsAdmin *bool `json:"is_admin"`
}
// handleUpdateUser promotes or demotes an administrator.
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
var req updateUserRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
if req.IsAdmin == nil {
w.WriteHeader(http.StatusNoContent)
return
}
// Standing down as the last administrator locks everybody out of this page,
// and there is no console and no second setup run to undo it with.
if !*req.IsAdmin {
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be demoted"); err != nil {
return
}
}
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
s.storeError(w, r, "set user admin", err)
return
}
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
w.WriteHeader(http.StatusNoContent)
}
// handleDeleteUser removes an account and everything the foreign keys take with
// it. See store.DeleteUser for what that is; it is more than it looks.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
// Not yourself. Deleting the account you are signed in as would take your own
// projects while you were looking at them, and end the session mid-request.
// Somebody who really means it can be demoted first and removed by another
// administrator.
if userID == me.ID {
writeError(w, http.StatusConflict, CodeConflict,
"you cannot delete the account you are signed in as")
return
}
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be deleted"); err != nil {
return
}
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
s.storeError(w, r, "delete user", err)
return
}
s.log.Info("user deleted", "by", me.ID, "user", userID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, r, "delete invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// refuseIfLastAdmin writes a 409 and returns an error when the named user is the
// only administrator left. The two callers both need it, and getting it wrong in
// one of them is an instance nobody can administer again.
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
target, err := s.db.UserByID(r.Context(), userID)
if err != nil {
s.storeError(w, r, "get user", err)
return err
}
if !target.IsAdmin {
return nil
}
admins, err := s.db.CountAdmins(r.Context())
if err != nil {
s.internal(w, r, "count admins", err)
return err
}
if admins <= 1 {
writeError(w, http.StatusConflict, CodeLastAdmin, message)
return errLastAdmin
}
return nil
}
+382
View File
@@ -0,0 +1,382 @@
package httpapi
import (
"net/http"
"net/url"
"testing"
)
// The instance's membership is an administrator's list, and creating an account
// means issuing an invite — there is no other door, and no password chosen by
// somebody other than its owner.
func TestAnAdminCanInviteSomebodyToTheInstance(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "ny@example.dk"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invite: %d %v", resp.StatusCode, body)
}
link, _ := body["link"].(string)
if link == "" {
t.Fatal("no link came back — with no mail server it is the only way the invite reaches anybody")
}
if body["emailed"] != false {
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
}
// It shows as pending, with no project attached: this is an invite to the
// instance, not to somewhere in it.
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
invites, _ := listed["invites"].([]any)
if len(invites) != 1 {
t.Fatalf("want one pending invite, got %v", invites)
}
invite := invites[0].(map[string]any)
if invite["email"] != "ny@example.dk" {
t.Errorf("email = %v", invite["email"])
}
if name, ok := invite["project_name"]; ok && name != "" {
t.Errorf("project_name = %v, want none for an instance invite", name)
}
// And the token in the link creates the account, through the same signup the
// project invites use.
token := link[len(link)-len(link)+indexAfter(link, "token="):]
fresh := newSignedOut(t, ts)
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
t.Fatalf("signup with the instance invite: status %d", resp.StatusCode)
}
// They are on the instance, and they are not an administrator.
_, after := ts.do(t, "GET", "/api/v1/users", nil)
users, _ := after["users"].([]any)
if len(users) != 2 {
t.Fatalf("want two accounts, got %v", users)
}
for _, raw := range users {
u := raw.(map[string]any)
if u["email"] == "ny@example.dk" && u["is_admin"] != false {
t.Errorf("a newly invited account is an administrator: %v", u)
}
}
// The invite is used up rather than left live.
if pending, _ := after["invites"].([]any); len(pending) != 0 {
t.Errorf("the invite is still pending after being accepted: %v", pending)
}
}
// The two refusals that make this page safe to have at all.
func TestTheLastAdministratorCannotBeRemoved(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
// Demoting yourself as the only administrator locks everybody out: there is no
// console, and setup refuses to run once an account exists.
resp, body := ts.do(t, "PATCH", "/api/v1/users/"+me.ID, map[string]any{"is_admin": false})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("demote the last admin: status %d, want 409", resp.StatusCode)
}
if body["code"] != "last_admin" {
t.Errorf("code = %v, want last_admin — the interface has to explain this one", body["code"])
}
// And deleting the account you are signed in as is refused before that check
// is even reached.
resp, _ = ts.do(t, "DELETE", "/api/v1/users/"+me.ID, nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("delete your own account: status %d, want 409", resp.StatusCode)
}
// Still an administrator, and still there.
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
first := listed["users"].([]any)[0].(map[string]any)
if first["is_admin"] != true || first["self"] != true {
t.Errorf("the only administrator is no longer one: %v", first)
}
}
// With a second administrator the refusal lifts — it is about the last one, not
// about administrators.
func TestASecondAdministratorCanBeDemoted(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anden@example.dk", "Anden")
other, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
map[string]any{"is_admin": true}); resp.StatusCode != http.StatusNoContent {
t.Fatalf("promote: status %d", resp.StatusCode)
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/users/"+other.ID,
map[string]any{"is_admin": false}); resp.StatusCode != http.StatusNoContent {
t.Errorf("demote a second administrator: status %d, want 204", resp.StatusCode)
}
}
// Deleting an account takes its own projects and leaves everything else behind.
// The list says both numbers before anybody presses the button.
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
if err != nil {
t.Fatal(err)
}
// One project of their own, with a task in it.
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "deres egen", "project_id": theirs["id"].(string),
})
// And a task they wrote in a project somebody else owns. This one survives —
// `created_by` is ON DELETE SET NULL — and it is counted separately, because
// "this much disappears" and "this much stays unattributed" are two different
// things to tell somebody who is about to press delete.
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
sharedID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
"email": "anden@example.dk", "role": "editor",
})
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "skrevet i et delt projekt", "project_id": sharedID,
})
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
var found map[string]any
for _, raw := range listed["users"].([]any) {
if u := raw.(map[string]any); u["id"] == otherUser.ID {
found = u
}
}
if found == nil {
t.Fatal("the second account is not in the list")
}
if found["project_count"] != float64(1) {
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
}
if found["task_count"] != float64(1) {
t.Errorf("task_count = %v, want 1 — only the task in their own project goes",
found["task_count"])
}
if found["authored_elsewhere"] != float64(1) {
t.Errorf("authored_elsewhere = %v, want 1 — the task in the shared project stays",
found["authored_elsewhere"])
}
// And the delete leaves it standing, without an author.
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
got, _ := tasks["tasks"].([]any)
if len(got) != 1 {
t.Fatalf("a task written by the deleted account should survive in a shared project, got %v", got)
}
surviving := got[0].(map[string]any)
if surviving["content"] != "skrevet i et delt projekt" {
t.Errorf("the surviving task is not the one that was written: %v", surviving["content"])
}
if surviving["created_by"] != "" {
t.Errorf("created_by = %q, want empty — the author is gone, the work is not",
surviving["created_by"])
}
}
// The tasks table was rebuilt to change created_by's delete action, and a rebuild
// is where an index quietly stops matching its table: tasks_fts is external-content
// and keyed on tasks.rowid, which a rebuilt table hands out afresh. Search is the
// only place that would notice.
func TestSearchStillFindsTasksAfterTheTableRebuild(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Have"})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "beskær det grønne hegn", "project_id": project["id"].(string),
})
for _, q := range []string{"grønne", "gronne", "hegn"} {
_, found := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(q), nil)
if got, _ := found["tasks"].([]any); len(got) != 1 {
t.Errorf("search for %q found %d tasks, want 1", q, len(got))
}
}
}
// Everything here is administrators only, and not reachable with an API token
// even by an administrator: a leaked token must not be able to mint an account.
func TestTheUserPageIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
for _, c := range []struct {
what, method, path string
body any
}{
{"list", "GET", "/api/v1/users", nil},
{"invite", "POST", "/api/v1/users", map[string]any{"email": "x@y.dk"}},
{"promote", "PATCH", "/api/v1/users/whoever", map[string]any{"is_admin": true}},
{"delete", "DELETE", "/api/v1/users/whoever", nil},
{"revoke", "DELETE", "/api/v1/invites/whichever", nil},
} {
if resp, _ := other.do(t, c.method, c.path, c.body); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", c.what, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/users", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("an administrator's API token reached the user list: status %d", resp.StatusCode)
}
}
// A pending invite can be withdrawn, which is the only way to stop a link that
// went to the wrong address: the token is stored as a hash and cannot be looked up.
func TestAPendingInviteCanBeWithdrawn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/users", map[string]any{"email": "forkert@example.dk"})
link := created["link"].(string)
_, listed := ts.do(t, "GET", "/api/v1/users", nil)
inviteID := listed["invites"].([]any)[0].(map[string]any)["id"].(string)
if resp, _ := ts.do(t, "DELETE", "/api/v1/invites/"+inviteID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("withdraw: status %d", resp.StatusCode)
}
// The link no longer works.
token := link[indexAfter(link, "token="):]
fresh := newSignedOut(t, ts)
resp, _ := fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Forkert", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a withdrawn invite still signs somebody up: status %d", resp.StatusCode)
}
}
// An address that already has an account is a conflict rather than a second
// invite: the signup would fail at the end, after somebody had chosen a password.
func TestInvitingAnAddressThatAlreadyHasAnAccount(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/users",
map[string]any{"email": "kristian@example.dk"})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status %d, want 409 — body %v", resp.StatusCode, body)
}
}
// newSignedOut is a client against the same server with an empty cookie jar.
func newSignedOut(t *testing.T, ts *testServer) *testServer {
t.Helper()
return &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
}
// indexAfter returns the offset just past `needle` in `s`.
func indexAfter(s, needle string) int {
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
return i + len(needle)
}
}
return 0
}
// A 500 has to leave something behind that a restart cannot take.
//
// The panel's watcher already reports that one happened. What it cannot report is
// what it was: that lives in the container's log, and a Rune replaces the
// container on every restart, so the explanation is usually gone before anybody
// looks.
func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Nothing has gone wrong yet.
_, empty := ts.do(t, "GET", "/api/v1/errors", nil)
if rows, _ := empty["errors"].([]any); len(rows) != 0 {
t.Fatalf("a fresh instance has errors already: %v", rows)
}
// Break something the handler cannot survive: the table it reads is gone, so
// the query fails and the handler answers 500. Dropping it is a blunt way to
// provoke that, and a faithful one — it is the same code path a disk error or
// a corrupt page would take.
if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil {
t.Fatal(err)
}
resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/errors", nil)
rows, _ := body["errors"].([]any)
if len(rows) != 1 {
t.Fatalf("want one recorded error, got %v", rows)
}
row := rows[0].(map[string]any)
if row["what"] != "list project groups" {
t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"])
}
if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" {
t.Errorf("path/method = %v %v", row["method"], row["path"])
}
if row["status"] != float64(500) {
t.Errorf("status = %v", row["status"])
}
if row["message"] == "" {
t.Error("no message: the row says something broke and not what")
}
if row["user_name"] != "Kristian" {
t.Errorf("user_name = %v — a fault only one account hits is a different problem",
row["user_name"])
}
if row["request_id"] == "" {
t.Error("no request id, so the row cannot be tied back to the log line")
}
}
// The list is an administrator's: it carries paths, accounts and the server's own
// error strings.
func TestTheErrorLogIsAdminsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want 403", resp.StatusCode)
}
}
+103 -5
View File
@@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
Limit: 500,
})
if err != nil {
s.internal(w, "today", err)
s.internal(w, r, "today", err)
return
}
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
Limit: 1000,
})
if err != nil {
s.internal(w, "upcoming", err)
s.internal(w, r, "upcoming", err)
return
}
@@ -92,6 +92,104 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": out})
}
// handlePeople lists everybody the caller shares a project with, so an interface
// can put a name and a face on an assignee id.
//
// One request per session rather than a lookup per row, and not the instance's
// user list — that is the administrator's page. A task list has no business
// enumerating everybody with an account here.
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list people", err)
return
}
out := make([]personJSON, 0, len(people))
for _, p := range people {
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
type personJSON struct {
ID string `json:"id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
}
type delegatedPerson struct {
UserID string `json:"user_id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
Tasks []taskJSON `json:"tasks"`
}
// handleDelegated is "waiting on others": everything assigned to somebody who is
// not the caller, in the projects the caller can see.
//
// Grouped by person rather than returned flat, because the question this view
// answers is about people — "what is Anders sitting on" — and a client grouping it
// itself would need every assignee's name, which it has no way to ask for. The
// names come back with the tasks, so one request is one screen.
//
// People are ordered by name, and their tasks by whatever the list query decided,
// which puts the overdue and the soonest first.
func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DelegatedBy: user.ID,
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, r, "delegated", err)
return
}
byPerson := map[string][]taskJSON{}
ids := []string{}
for _, t := range tasks {
if _, seen := byPerson[t.AssigneeID]; !seen {
ids = append(ids, t.AssigneeID)
}
byPerson[t.AssigneeID] = append(byPerson[t.AssigneeID], toTaskJSON(t))
}
people, err := s.db.PeopleByIDs(r.Context(), ids)
if err != nil {
s.internal(w, r, "delegated people", err)
return
}
out := make([]delegatedPerson, 0, len(ids))
for _, id := range ids {
// A person who no longer has an account still has their id on the task.
// Naming them "Ukendt" beats dropping the tasks, which would make work
// disappear from the one view that exists to stop that happening.
p, ok := people[id]
if !ok {
p = store.Person{ID: id, Name: "Ukendt", AvatarColor: "#8a8f98"}
}
out = append(out, delegatedPerson{
UserID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor, Tasks: byPerson[id],
})
}
sortByName(out)
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
// sortByName orders the groups the way a list of people reads. Insertion sort:
// this is a handful of entries, and it keeps the package free of a dependency on
// sort just for this.
func sortByName(people []delegatedPerson) {
for i := 1; i < len(people); i++ {
for j := i; j > 0 && lower(people[j].Name) < lower(people[j-1].Name); j-- {
people[j], people[j-1] = people[j-1], people[j]
}
}
}
// handleSearch is what Cmd+K calls. It searches across every project the user can
// see, which is the point — the thing you are looking for is usually somewhere you
// were not looking.
@@ -111,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
if err != nil {
s.internal(w, "search tasks", err)
s.internal(w, r, "search tasks", err)
return
}
@@ -119,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// second FTS index for that would be more machinery than the problem needs.
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil {
s.internal(w, "search projects", err)
s.internal(w, r, "search projects", err)
return
}
matched := []projectJSON{}
@@ -141,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
parseLimit(r.URL.Query().Get("limit"), 50, 200))
if err != nil {
s.internal(w, "list activity", err)
s.internal(w, r, "list activity", err)
return
}
+202
View File
@@ -0,0 +1,202 @@
package httpapi
import (
"net/http"
"testing"
)
// A label is how you find a task whose wording you have forgotten. Search
// covered the text only, so "regnskab" found nothing even with the task
// labelled @regnskab in front of you.
func TestSearchFindsTasksByLabel(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "ring til revisoren @regnskab"})
ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "køb kaffe"})
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
tasks, _ := body["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("found %d tasks searching for a label, want 1: %v", len(tasks), body)
}
if got := tasks[0].(map[string]any)["content"]; got != "ring til revisoren" {
t.Errorf("found %v", got)
}
}
// The text search still works, and a label match does not swallow it.
func TestSearchStillFindsTasksByText(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": "køb grøn maling"})
// Diacritic folding still applies to the text side.
_, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
if tasks, _ := body["tasks"].([]any); len(tasks) != 1 {
t.Errorf("searching \"gron\" found %d tasks, want 1", len(tasks))
}
}
// Somebody else's label must not pull their task into your results — the label
// join has to stay inside the visibility the outer query already applies.
func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
other.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "deres hemmelighed @regnskab"})
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
if tasks, _ := body["tasks"].([]any); len(tasks) != 0 {
t.Errorf("found somebody else's task: %v", tasks)
}
}
// "Waiting on others" is the other half of an assignee filter, and the half that
// is easy to get backwards: what somebody has handed over, not what they have
// been handed.
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anders@example.dk", "Anders")
anders, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
if err != nil {
t.Fatal(err)
}
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anders@example.dk", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
})
// Mine, and unassigned: neither belongs in a view about other people.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "ingen har den", "project_id": projectID,
})
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
}
people, _ := body["people"].([]any)
if len(people) != 1 {
t.Fatalf("want one person, got %v", people)
}
person := people[0].(map[string]any)
if person["name"] != "Anders" {
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
person["name"])
}
if person["avatar_color"] == nil || person["avatar_color"] == "" {
t.Errorf("no avatar colour: %v", person)
}
tasks, _ := person["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("want one task, got %v", tasks)
}
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
t.Errorf("content = %v", tasks[0])
}
}
// The view is per person: Anders is not waiting on himself, and he cannot see
// what somebody has delegated in a project he is not in.
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anders := ts.newUser(t, "anders@example.dk", "Anders")
andersUser, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
if err != nil {
t.Fatal(err)
}
// A project Anders is not a member of.
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anders@example.dk", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
})
// It is assigned to him, so it is not something he is waiting on.
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := body["people"].([]any); len(people) != 0 {
t.Errorf("Anders is waiting on himself: %v", people)
}
// And somebody with no access to the project sees none of it.
other := ts.newUser(t, "tredje@example.dk", "Tredje")
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := third["people"].([]any); len(people) != 0 {
t.Errorf("a task in a project they cannot see: %v", people)
}
}
// /people is what puts a name on an assignee id. It must be the people you share
// work with — not the instance's user list, which is the administrator's page.
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
colleague := ts.newUser(t, "anders@example.dk", "Anders")
ts.newUser(t, "fremmed@example.dk", "Fremmed")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites", map[string]any{
"email": "anders@example.dk", "role": "editor",
})
_, body := ts.do(t, "GET", "/api/v1/people", nil)
names := map[string]bool{}
for _, raw := range body["people"].([]any) {
p := raw.(map[string]any)
names[p["name"].(string)] = true
if p["avatar_color"] == "" {
t.Errorf("no avatar colour for %v — the row cannot draw a face", p["name"])
}
}
if !names["Kristian"] {
t.Error("yourself is missing, so a client cannot tell 'me' from 'somebody else' against one list")
}
if !names["Anders"] {
t.Error("somebody sharing a project is missing")
}
if names["Fremmed"] {
t.Error("an account with no project in common is listed — this is not the user directory")
}
// And it is symmetric: the person invited sees the person who invited them.
_, theirs := colleague.do(t, "GET", "/api/v1/people", nil)
seen := map[string]bool{}
for _, raw := range theirs["people"].([]any) {
seen[raw.(map[string]any)["name"].(string)] = true
}
if !seen["Kristian"] || !seen["Anders"] {
t.Errorf("the member cannot see who they share with: %v", seen)
}
if seen["Fremmed"] {
t.Errorf("a stranger is listed: %v", seen)
}
}
+116
View File
@@ -0,0 +1,116 @@
// Package i18n exists only to hold this test.
//
// The interface's strings live in the frontend, where they are read; nothing in Go
// needs them. What Go can do is fail the build when the two dictionaries drift,
// which is the failure mode a translation always has and which nothing else here
// would notice: a key present in Danish and missing from English renders as Danish
// inside an English screen, and a key present only in English is a string nobody
// will ever see.
//
// A scanner rather than a parser, the same approach `colors_test.go` and
// `openapi_test.go` take: it needs the keys out of two lists this repository owns,
// and that is worth more than a dependency in a project whose whole shape is "one
// static binary".
package i18n
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
func TestTheTwoDictionariesHaveTheSameKeys(t *testing.T) {
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
da := readKeys(t, filepath.Join(dir, "da.js"))
en := readKeys(t, filepath.Join(dir, "en.js"))
if len(da) == 0 {
t.Fatal("no keys found in da.js — the scanner is looking at the wrong shape")
}
var missing, extra []string
for key := range da {
if !en[key] {
missing = append(missing, key)
}
}
for key := range en {
if !da[key] {
extra = append(extra, key)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 {
t.Errorf("%d keys are in da.js and not in en.js — they render as Danish inside an "+
"English screen:\n %s", len(missing), strings.Join(missing, "\n "))
}
if len(extra) > 0 {
t.Errorf("%d keys are in en.js and not in da.js — nothing reads them, because Danish "+
"is the source:\n %s", len(extra), strings.Join(extra, "\n "))
}
}
// The placeholders have to match too. `t()` fills `{name}` from what the caller
// passes, so a translation that spells one differently leaves `{navn}` on the
// screen — which is worse than an untranslated sentence, because it looks like a
// bug in the app rather than a gap in the dictionary.
func TestTheTwoDictionariesUseTheSamePlaceholders(t *testing.T) {
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
da := readEntries(t, filepath.Join(dir, "da.js"))
en := readEntries(t, filepath.Join(dir, "en.js"))
for key, danish := range da {
english, ok := en[key]
if !ok {
continue // reported by the test above
}
want := placeholders(danish)
got := placeholders(english)
if strings.Join(want, ",") != strings.Join(got, ",") {
t.Errorf("%s: Danish fills %v, English fills %v", key, want, got)
}
}
}
var (
// `'some.key': 'the string',` — single-quoted keys and values, which is what
// prettier writes for these files.
reEntry = regexp.MustCompile(`(?m)^\s*'([a-zA-Z0-9._]+)':\s*(.*?),?\s*$`)
rePlaceholder = regexp.MustCompile(`\{(\w+)\}`)
)
func readKeys(t *testing.T, path string) map[string]bool {
t.Helper()
keys := map[string]bool{}
for key := range readEntries(t, path) {
keys[key] = true
}
return keys
}
func readEntries(t *testing.T, path string) map[string]string {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", filepath.Base(path), err)
}
out := map[string]string{}
for _, m := range reEntry.FindAllStringSubmatch(string(body), -1) {
out[m[1]] = m[2]
}
return out
}
func placeholders(s string) []string {
var found []string
for _, m := range rePlaceholder.FindAllStringSubmatch(s, -1) {
found = append(found, m[1])
}
sort.Strings(found)
return found
}
+153
View File
@@ -0,0 +1,153 @@
package i18n
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Danish prose left in a component is the failure the dictionaries cannot see.
//
// `i18n_test.go` checks that the two dictionaries agree. It cannot check the thing
// that actually went wrong: a string that was never put in either, because nobody
// noticed it. Three survived the sweep — "Ingen nyere version fundet.", "Ikke mere
// i dag." and a token hint split across two lines — and they survived it for the
// same reason. The sweep grepped for æøå, and none of them contain one.
//
// So this reads the templates instead and looks for the words that give Danish
// 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 —
// and is not what this is looking for.
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
// Words that are Danish and are not also English. "is", "for" and "to" would
// match English prose and are deliberately absent.
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|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
styles := regexp.MustCompile(`(?s)<style>.*`)
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() || !strings.HasSuffix(path, ".svelte") {
return err
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
markup := string(body)
if at := strings.Index(markup, "</script>"); at > 0 {
markup = markup[at:]
}
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
rel, _ := filepath.Rel(root, path)
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
attrs.FindAllStringSubmatch(markup, -1)...) {
phrase := strings.TrimSpace(m[1])
if danish.MatchString(phrase) {
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
}
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
if len(found) > 0 {
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
"t(). An English interface shows them in Danish, and neither dictionary knows "+
"they exist:\n %s", len(found), strings.Join(found, "\n "))
}
}
+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"}}, "kw@nolimit.dk"},
{"with a name", []imap.Address{{Name: "Kristian Wind", Mailbox: "kw", Host: "nolimit.dk"}},
"Kristian Wind <kw@nolimit.dk>"},
// 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"},
}, "first@example.dk"},
} {
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 <anders@example.dk>", body: "Vedhæftet.", flagged: true},
{subject: "Nyhedsbrev", from: "noreply@example.com", body: "Læs mere", flagged: false},
{subject: "Kontrakt", from: "jura@example.dk", 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, "anders@example.dk") {
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: "a@example.dk", body: "en", flagged: true},
{subject: "To", from: "b@example.dk", 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: kontakt@mikrobagt.dk\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: "a@example.dk", body: "en", flagged: true},
{subject: "To", from: "b@example.dk", body: "to", flagged: true},
{subject: "Tre", from: "c@example.dk", 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)
}
}
}
}
+118 -49
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
}
@@ -55,11 +63,12 @@ func (r *Runner) Start(ctx context.Context) {
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
r.every(ctx, "trash", time.Hour, r.emptyTrash)
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
// Ten minutes is a compromise: Gmail's push notifications would be instant but
// 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() }
@@ -121,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 {
@@ -134,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 (
@@ -161,22 +185,41 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
if time.Since(last) < 23*time.Hour {
return nil
}
return Backup(ctx, r.cfg, r.db, r.log)
}
// Backup takes one snapshot now, records the run and rotates the old files.
//
// Exported and free of the Runner, because the settings page can ask for one too:
// waiting until tonight to find out whether backups work at all is how somebody
// discovers on the day they need one that the volume has been read-only for a
// month. The nightly job is this function behind a "has one run today" check; the
// button is this function without it.
//
// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a
// live WAL-mode database can miss the most recent writes or catch a page
// mid-update; VACUUM INTO produces a consistent, already-compacted database
// without blocking writers for the duration.
func Backup(ctx context.Context, cfg *config.Config, db *store.DB, log *slog.Logger) error {
started := time.Now()
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
path := filepath.Join(r.cfg.BackupsDir(), name)
path := filepath.Join(cfg.BackupsDir(), name)
runID, err := r.db.StartBackupRun(ctx, started)
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
return fmt.Errorf("backups dir: %w", err)
}
runID, err := db.StartBackupRun(ctx, started)
if err != nil {
return err
}
if err := r.db.Snapshot(ctx, path); err != nil {
if err := db.Snapshot(ctx, path); err != nil {
// A failed attempt can leave a partial file, which would then look like a
// backup to anybody reading the directory.
os.Remove(path)
if recErr := r.db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
r.log.Warn("record backup failure", "err", recErr)
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
log.Warn("record backup failure", "err", recErr)
}
return fmt.Errorf("snapshot: %w", err)
}
@@ -185,13 +228,13 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
if info, err := os.Stat(path); err == nil {
size = info.Size()
}
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
r.log.Warn("record backup", "err", err)
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
log.Warn("record backup", "err", err)
}
r.log.Info("backup written", "path", path, "bytes", size,
log.Info("backup written", "path", path, "bytes", size,
"took", time.Since(started).Round(time.Millisecond).String())
return r.rotateBackups()
return rotateBackups(cfg, log)
}
// rotateBackups keeps the most recent fourteen and deletes the rest.
@@ -199,8 +242,8 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
// Counted by file, not by age: a container that was off for a month would
// otherwise come back, find every backup older than fourteen days, and delete all
// of them — leaving none at exactly the moment they were most likely to be needed.
func (r *Runner) rotateBackups() error {
entries, err := os.ReadDir(r.cfg.BackupsDir())
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
entries, err := os.ReadDir(cfg.BackupsDir())
if err != nil {
return err
}
@@ -215,38 +258,65 @@ func (r *Runner) rotateBackups() error {
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
for _, name := range backups[min(len(backups), backupKeepDays):] {
path := filepath.Join(r.cfg.BackupsDir(), name)
path := filepath.Join(cfg.BackupsDir(), name)
if err := os.Remove(path); err != nil {
r.log.Warn("remove old backup", "err", err, "path", path)
log.Warn("remove old backup", "err", err, "path", path)
continue
}
r.log.Info("old backup removed", "path", path)
log.Info("old backup removed", "path", path)
}
return nil
}
// --- 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
@@ -265,6 +335,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error {
return nil
}
// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer
// "what happened last week" and short enough that a fault loop cannot fill the
// volume the tasks live on.
func (r *Runner) purgeErrors(ctx context.Context) error {
n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour)
if err != nil {
return err
}
if n > 0 {
r.log.Debug("old error rows removed", "count", n)
}
return nil
}
func (r *Runner) purgeSessions(ctx context.Context) error {
n, err := r.db.PurgeExpiredSessions(ctx)
if err != nil {
@@ -276,27 +360,12 @@ func (r *Runner) purgeSessions(ctx context.Context) error {
return nil
}
// RunBackupNow is what the "back up now" button calls, and what the tests use.
// RunBackupNow is the Runner's way in, for the tests that already hold one.
//
// It was a second copy of Backup — written when the button that would call it did
// not exist yet — and the two had already drifted: this one never created the
// backups directory, and logged nothing. One implementation, so a fix to the
// nightly path is a fix to the button as well.
func (r *Runner) RunBackupNow(ctx context.Context) error {
started := time.Now()
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
path := filepath.Join(r.cfg.BackupsDir(), name)
runID, err := r.db.StartBackupRun(ctx, started)
if err != nil {
return err
}
if err := r.db.Snapshot(ctx, path); err != nil {
os.Remove(path)
_ = r.db.FinishBackupRun(ctx, runID, "", 0, err)
return err
}
var size int64
if info, err := os.Stat(path); err == nil {
size = info.Size()
}
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
return err
}
return r.rotateBackups()
return Backup(ctx, r.cfg, r.db, r.log)
}
+57 -1
View File
@@ -154,7 +154,7 @@ func TestBackupsRotateByCountNotAge(t *testing.T) {
}
}
if err := runner.rotateBackups(); err != nil {
if err := rotateBackups(cfg, runner.log); err != nil {
t.Fatalf("rotate: %v", err)
}
@@ -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))
}
}
+31 -1
View File
@@ -34,6 +34,7 @@ const (
KindTime Kind = "time"
KindPriority Kind = "priority"
KindProject Kind = "project"
KindSection Kind = "section"
KindLabel Kind = "label"
KindRepeat Kind = "repeat"
)
@@ -52,7 +53,11 @@ type Result struct {
Content string `json:"content"`
// Project is the name written after '#', not an id: the caller resolves it
// against the user's projects and decides what to do when it matches nothing.
Project string `json:"project,omitempty"`
Project string `json:"project,omitempty"`
// Section is the name written after '/', resolved by the caller against the
// sections of whichever project the task lands in. A section belongs to one
// project, so it cannot be looked up until the project is known.
Section string `json:"section,omitempty"`
Labels []string `json:"labels,omitempty"`
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
Priority int `json:"priority"`
@@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result {
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
// called "Fredag" from later being read as a weekday.
res.Project, spans = extractProject(input, spans)
res.Section, spans = extractSection(input, spans)
res.Labels, spans = extractLabels(input, spans)
res.Priority, spans = extractPriority(input, spans)
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
var (
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
//
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
// name — so the bare form is greedy and swallows the lot.
//
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
// three are the shapes a slash actually takes in a Danish task line.
reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
)
func extractProject(input string, spans []Span) (string, []Span) {
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
return name, spans
}
// Last one wins, as with the project: retyping is a correction, not two sections.
func extractSection(input string, spans []Span) (string, []Span) {
all := reSection.FindAllStringSubmatchIndex(input, -1)
if len(all) == 0 {
return "", spans
}
var name string
for _, m := range all {
name = submatch(input, m)
spans = append(spans, sigilSpan(input, m, KindSection))
}
return name, spans
}
func extractLabels(input string, spans []Span) ([]string, []Span) {
var labels []string
seen := map[string]bool{}
+43
View File
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
t.Error("no span of kind repeat")
}
}
// A section, and the three shapes a slash takes in ordinary text.
//
// The sigil is the one that needed thinking about: '#' and '@' never occur in a
// Danish task line by accident, and '/' occurs constantly. What keeps it apart is
// that the slash must follow whitespace and the name must follow the slash with
// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail.
func TestASectionIsWrittenWithASlash(t *testing.T) {
for _, c := range []struct {
in string
section string
content string
}{
{"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"},
{"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"},
// A real section name with a slash in it. The bare form is greedy, so the
// whole thing comes across rather than stopping at the second slash.
{"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"},
{`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"},
// Retyping is a correction, not two sections.
{"nye poser /Drift /Produktion", "Produktion", "nye poser"},
// And alongside everything else.
{"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"},
// The ways a slash shows up when nobody meant a section. None of them may
// produce one: the slash has to follow whitespace, and the name has to
// follow the slash with nothing between.
{"kør 80 km/t forbi", "", "kør 80 km/t forbi"},
{"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"},
// "3/4" is a section to nobody and a date to a Dane — the 3rd of April —
// and the date parser is right to claim it. What matters here is only that
// it did not become a section.
{"læs 3/4 af rapporten", "", "læs af rapporten"},
} {
got := Parse(c.in, ref(t), "da")
if got.Section != c.section {
t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section)
}
if got.Content != c.content {
t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content)
}
}
}
+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
}
+141 -4
View File
@@ -5,13 +5,19 @@ import (
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
)
type Activity struct {
ID string
ProjectID string
TaskID string
// ProjectName is filled by the instance-wide log, where a row without it says
// nothing: "renamed a section" needs to say which project's section.
ProjectName string
TaskID string
// UserID and UserName are empty when the account has been deleted. The record
// of what was done outlives whoever did it — see migration 0009.
UserID string
UserName string
Event string
@@ -19,6 +25,23 @@ type Activity struct {
CreatedAt time.Time
}
// AuditFilter narrows the instance-wide log. Zero values mean "do not filter".
type AuditFilter struct {
UserID string
ProjectID string
// Event matches exactly, e.g. "task.completed". The interface offers the set
// that actually occurs rather than a text field, so there is no prefix or
// wildcard to support here.
Event string
// Before pages backwards. Keyed on (created_at, id) rather than on created_at
// alone: the log is written in whole seconds, so a busy moment puts several
// rows on the same timestamp and a cursor that only knew the time would either
// skip them or repeat them.
BeforeAt int64
BeforeID string
Limit int
}
// RecordActivity appends to a project's history.
//
// Every mutation writes one, which is what makes the activity log complete rather
@@ -35,7 +58,7 @@ func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, eve
_, err := db.ExecContext(ctx,
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix())
return err
}
@@ -43,9 +66,12 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
if limit <= 0 || limit > 200 {
limit = 50
}
// LEFT JOIN, not JOIN: a row whose author has been deleted still happened, and
// an inner join would drop it from the history silently.
rows, err := db.QueryContext(ctx, `
SELECT a.id, a.project_id, a.task_id, a.user_id, u.name, a.event, a.payload_json, a.created_at
FROM activity a JOIN users u ON u.id = a.user_id
SELECT a.id, a.project_id, a.task_id, COALESCE(a.user_id, ''), COALESCE(u.name, ''),
a.event, a.payload_json, a.created_at
FROM activity a LEFT JOIN users u ON u.id = a.user_id
WHERE a.project_id = ?
ORDER BY a.created_at DESC, a.id DESC
LIMIT ?`, projectID, limit)
@@ -72,6 +98,117 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
return out, rows.Err()
}
// ListAuditLog is the same history read across the whole instance rather than one
// project at a time.
//
// The per-project log answers "what happened here", which is a question a project's
// members ask about their own work. This one answers "what has happened on this
// server", which only an administrator asks and which nothing in the API could
// answer before: ListActivity takes one project id, and an administrator is not
// necessarily a member of the project they need to look into.
//
// Reading is a keyset walk rather than an OFFSET. The log is append-heavy and read
// rarely, so a page fetched a minute after the one before it would otherwise be
// shifted by everything written in between — showing a row twice and skipping the
// one behind it, which is precisely the failure an audit log must not have.
func (db *DB) ListAuditLog(ctx context.Context, f AuditFilter) ([]Activity, error) {
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
where := []string{}
args := []any{}
if f.UserID != "" {
where = append(where, "a.user_id = ?")
args = append(args, f.UserID)
}
if f.ProjectID != "" {
where = append(where, "a.project_id = ?")
args = append(args, f.ProjectID)
}
if f.Event != "" {
where = append(where, "a.event = ?")
args = append(args, f.Event)
}
if f.BeforeAt > 0 {
// A row value, so the tie-break on id is part of the comparison rather
// than a second clause that would have to repeat the timestamp.
where = append(where, "(a.created_at, a.id) < (?, ?)")
args = append(args, f.BeforeAt, f.BeforeID)
}
clause := ""
if len(where) > 0 {
clause = "WHERE " + strings.Join(where, " AND ")
}
args = append(args, limit)
rows, err := db.QueryContext(ctx, `
SELECT a.id, a.project_id, p.name, a.task_id,
COALESCE(a.user_id, ''), COALESCE(u.name, ''),
a.event, a.payload_json, a.created_at
FROM activity a
JOIN projects p ON p.id = a.project_id
LEFT JOIN users u ON u.id = a.user_id
`+clause+`
ORDER BY a.created_at DESC, a.id DESC
LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Activity{}
for rows.Next() {
var a Activity
var taskID sql.NullString
var payload string
var created int64
if err := rows.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &taskID, &a.UserID,
&a.UserName, &a.Event, &payload, &created); err != nil {
return nil, err
}
a.TaskID = taskID.String
a.CreatedAt = time.Unix(created, 0).UTC()
_ = json.Unmarshal([]byte(payload), &a.Payload)
out = append(out, a)
}
return out, rows.Err()
}
// AuditEvents is the set of event names the log actually contains, most frequent
// first, with how many of each.
//
// The filter offers these rather than a text field. Event names are an internal
// vocabulary — "member.role_changed", not a sentence anybody would guess — so a
// field to type them into is a field that returns nothing until you have read the
// source. The counts are worth having on their own: they are the shape of what the
// instance does.
func (db *DB) AuditEvents(ctx context.Context) ([]EventCount, error) {
rows, err := db.QueryContext(ctx, `
SELECT event, count(*) FROM activity
GROUP BY event ORDER BY count(*) DESC, event`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []EventCount{}
for rows.Next() {
var e EventCount
if err := rows.Scan(&e.Event, &e.Count); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
type EventCount struct {
Event string
Count int
}
// PurgeActivity drops history older than the retention window. An activity log that
// grows without limit turns a 10 MB database into a 2 GB one over a couple of years
// of ordinary use, on hardware that is somebody's spare machine.
+39
View File
@@ -0,0 +1,39 @@
package store
// The colours a project, group, label or filter may be marked with.
//
// Names rather than hex values, and the reason is what happens later: a hex in
// ten thousand rows is a decision nobody can revisit, while a name is resolved by
// the interface at the moment it paints and can be retuned for a new theme, a new
// palette, or a display nobody has yet. It is also the difference between a
// database that stores a fact and one that stores a rendering.
//
// The list is closed. An unknown name would be stored happily and then paint as
// the default, which looks like a colour that did not save — so it is refused at
// the edge instead. web/src/lib/colors.js carries the same ids for the picker, and
// a test reads that file to check the two have not drifted.
var Colors = []string{
"graphite",
"tomato",
"amber",
"lime",
"green",
"teal",
"blue",
"indigo",
"violet",
"magenta",
}
// DefaultColor is what an unmarked thing is, and what the schema defaults to.
const DefaultColor = "graphite"
// ValidColor reports whether a name is one this app can paint.
func ValidColor(name string) bool {
for _, c := range Colors {
if c == name {
return true
}
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The palette is written down three times: here, in web/src/lib/colors.js for the
// picker, and in web/src/app.css as the tokens that actually paint. Three copies
// is two chances to drift, and the failure is quiet — a colour the server accepts
// and the interface cannot draw renders as the default, which looks like a colour
// that did not save.
//
// So this reads the other two. A small scanner rather than a parser, the same
// approach openapi_test.go takes to the spec: it needs the ids out of a list this
// repository also owns, and that is worth more than a dependency in a project
// whose whole shape is "one static binary".
func TestThePaletteIsTheSameInGoAndInTheFrontend(t *testing.T) {
js := readFrontendColors(t, filepath.Join("..", "..", "web", "src", "lib", "colors.js"))
css := readColorTokens(t, filepath.Join("..", "..", "web", "src", "app.css"))
for _, name := range Colors {
if !js[name] {
t.Errorf("%q is accepted by the server and missing from colors.js — the picker cannot offer it", name)
}
if !css[name] {
t.Errorf("--color-%s is missing from app.css — the server accepts a colour nothing can paint", name)
}
}
for name := range js {
if !ValidColor(name) {
t.Errorf("colors.js offers %q, which the server refuses — picking it would fail to save", name)
}
}
}
// readFrontendColors pulls the ids out of the COLORS array: lines shaped
// `{ id: 'tomato', name: 'Tomat' },`.
func readFrontendColors(t *testing.T, path string) map[string]bool {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read colors.js: %v", err)
}
found := map[string]bool{}
for _, line := range strings.Split(string(body), "\n") {
id, ok := between(line, "{ id: '", "'")
if ok {
found[id] = true
}
}
if len(found) == 0 {
t.Fatal("no colours were read out of colors.js — the scanner and the file disagree")
}
return found
}
// readColorTokens pulls the names out of `--color-tomato: #d9614f;`.
func readColorTokens(t *testing.T, path string) map[string]bool {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read app.css: %v", err)
}
found := map[string]bool{}
for _, line := range strings.Split(string(body), "\n") {
name, ok := between(line, "--color-", ":")
if ok {
found[name] = true
}
}
if len(found) == 0 {
t.Fatal("no --color- tokens were read out of app.css")
}
return found
}
func between(line, open, close string) (string, bool) {
start := strings.Index(line, open)
if start < 0 {
return "", false
}
rest := line[start+len(open):]
end := strings.Index(rest, close)
if end <= 0 {
return "", false
}
return rest[:end], true
}
+30 -13
View File
@@ -21,9 +21,12 @@ type Comment struct {
}
type Attachment struct {
ID string
TaskID string
CommentID string
ID string
TaskID string
CommentID string
// GroupID hangs the file on a project group rather than on any one task in
// it: the contract that governs all of "Arbejde", not a step in it.
GroupID string
Filename string
MimeType string
Size int64
@@ -154,17 +157,17 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
a.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO attachments (id, task_id, comment_id, filename, mime_type, size,
path, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, nullString(a.TaskID), nullString(a.CommentID), a.Filename,
a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
`INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type,
size, path, uploaded_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
return err
}
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
if err != nil {
return nil, err
@@ -173,13 +176,26 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
return scanAttachments(rows)
}
// ListGroupAttachments is the documents that belong to a whole group rather than
// to anything inside it.
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAttachments(rows)
}
func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (map[string][]Attachment, error) {
args := make([]any, len(commentIDs))
for i, id := range commentIDs {
args[i] = id
}
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
ORDER BY created_at`, args...)
if err != nil {
@@ -202,14 +218,15 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
out := []Attachment{}
for rows.Next() {
var a Attachment
var taskID, commentID sql.NullString
var taskID, commentID, groupID sql.NullString
var created int64
if err := rows.Scan(&a.ID, &taskID, &commentID, &a.Filename, &a.MimeType,
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &a.Filename, &a.MimeType,
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
return nil, err
}
a.TaskID = taskID.String
a.CommentID = commentID.String
a.GroupID = groupID.String
a.CreatedAt = time.Unix(created, 0).UTC()
out = append(out, a)
}
@@ -218,7 +235,7 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
FROM attachments WHERE id = ?`, attachmentID)
if err != nil {
return nil, err
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"context"
"time"
)
// A ServerError is one 500 the API answered, kept so it can be read after the
// container that produced it has gone.
type ServerError struct {
ID string
At time.Time
Method string
Path string
Status int
What string
Message string
UserID string
UserName string
RequestID string
}
// RecordError stores one failure.
//
// Errors here are dropped rather than returned: this is called from the path that
// is already reporting a failure to somebody, and a diagnostic that can itself
// fail the request would turn one broken screen into two.
func (db *DB) RecordError(ctx context.Context, e ServerError) {
var user any
if e.UserID != "" {
user = e.UserID
}
_, _ = db.ExecContext(ctx,
`INSERT INTO error_log (id, at, method, path, status, what, message, user_id, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
NewID(), time.Now().Unix(), e.Method, truncate(e.Path, 500), e.Status,
truncate(e.What, 200), truncate(e.Message, 2000), user, truncate(e.RequestID, 100))
}
// ListErrors returns the most recent failures, newest first.
func (db *DB) ListErrors(ctx context.Context, limit int) ([]ServerError, error) {
rows, err := db.QueryContext(ctx, `
SELECT e.id, e.at, e.method, e.path, e.status, e.what, e.message,
COALESCE(e.user_id, ''), COALESCE(u.name, ''), e.request_id
FROM error_log e
LEFT JOIN users u ON u.id = e.user_id
ORDER BY e.at DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ServerError{}
for rows.Next() {
var e ServerError
var at int64
if err := rows.Scan(&e.ID, &at, &e.Method, &e.Path, &e.Status, &e.What,
&e.Message, &e.UserID, &e.UserName, &e.RequestID); err != nil {
return nil, err
}
e.At = time.Unix(at, 0).UTC()
out = append(out, e)
}
return out, rows.Err()
}
// PurgeOldErrors is run by the nightly sweep. A diagnostic that grows without
// limit is a disk that fills for a reason nobody expected.
func (db *DB) PurgeOldErrors(ctx context.Context, keep time.Duration) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM error_log WHERE at < ?`, time.Now().Add(-keep).Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+64
View File
@@ -136,6 +136,70 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
return &inv, nil
}
// PendingInvite is an invite that has been sent and not yet used, as the
// administrator's list shows it. The token is not here and cannot be: only its
// hash is stored, so a link that has gone astray is revoked rather than resent.
type PendingInvite struct {
ID string
Email string
ProjectID string
ProjectName string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// ListPendingInvites returns invites that are neither accepted nor expired.
//
// Expired ones are left out rather than shown greyed: an invite past its date is
// not a thing anybody can act on, and a list of them would grow forever. The
// project name is joined in because an invite with no project means something
// different — an invitation to the instance itself — and the interface has to be
// able to say which it is.
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
COALESCE(u.name, ''), i.created_at, i.expires_at
FROM invites i
LEFT JOIN projects p ON p.id = i.project_id
LEFT JOIN users u ON u.id = i.created_by
WHERE i.accepted_at IS NULL AND i.expires_at > ?
ORDER BY i.created_at DESC`, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []PendingInvite{}
for rows.Next() {
var p PendingInvite
var created, expires int64
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
&p.InvitedBy, &created, &expires); err != nil {
return nil, err
}
p.CreatedAt = time.Unix(created, 0).UTC()
p.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, p)
}
return out, rows.Err()
}
// DeleteInvite revokes an invite, which is what makes a link sent to the wrong
// address recoverable: the token cannot be looked up, so withdrawing it is the
// only way to stop it working before it expires.
func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
res, err := db.ExecContext(ctx, `DELETE FROM invites WHERE id = ?`, inviteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
+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: "kw@icloud.com",
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: "andreas@example.dk", 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":"kw@nolimit.dk","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 != "kw@nolimit.dk" {
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: "kristian@example.dk", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
@@ -0,0 +1,33 @@
-- Foldable groups over the projects in the sidebar.
--
-- A table rather than a `group_name` column on the project: a group has to be
-- renamable in one write, has to survive being emptied, and has to remember
-- whether it is folded. A repeated string on every project row can do none of
-- those — renaming would touch N rows, and an empty group would simply cease to
-- exist the moment its last project left it.
CREATE TABLE project_groups (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- Folded state lives on the row, not in localStorage.
--
-- The sidebar's *width* is a property of the screen you are sitting at, which
-- is why that one is stored per browser. A folded group is not: it is a
-- statement about that group — "this is the part of my work I am not in right
-- now" — and it is true on the laptop and the desktop alike. A group is also
-- owned by exactly one person, so there is nobody else's answer to disagree
-- with.
collapsed INTEGER NOT NULL DEFAULT 0,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_project_groups_owner ON project_groups (owner_id);
-- ON DELETE SET NULL: deleting a group is a decision about the heading, not about
-- the projects underneath it. They fall back to ungrouped, in the order they
-- already had.
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups (id) ON DELETE SET NULL;
CREATE INDEX idx_projects_group ON projects (group_id) WHERE deleted_at IS NULL;
@@ -0,0 +1,11 @@
-- A colour on a sidebar group, so a group can be marked the way a project is.
--
-- Projects, labels and filters have carried a `color` since 0001; groups arrived
-- in 0005 without one because nothing rendered any of them yet. Now that the
-- sidebar paints them, a group without one would be the only thing in the list
-- that could not be told apart at a glance.
--
-- Same default and same spelling as the others: a colour name, not a hex. The
-- value has to survive a theme change and a redesign, and '#8a8f98' in ten
-- thousand rows is a decision nobody can revisit.
ALTER TABLE project_groups ADD COLUMN color TEXT NOT NULL DEFAULT 'graphite';
@@ -0,0 +1,27 @@
-- Server errors, kept where they can outlive the container that produced them.
--
-- verdande already logs the real cause of every 500 to stdout. On a Rune that is
-- the container's log, and every restart starts a new one — so the panel can
-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone.
-- An operator is then told that something broke and given no way to find out what.
--
-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that
-- grows without limit is a disk that fills for a reason nobody expected.
CREATE TABLE error_log (
id TEXT PRIMARY KEY,
at INTEGER NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL,
-- what: the operation the handler was attempting, in the words it uses in the
-- log — "list projects", "move task". The detail a stack trace would give and
-- a status code cannot.
what TEXT NOT NULL,
message TEXT NOT NULL,
-- Whose request it was, so a fault that only one account hits is visible as
-- one. NULL for anything that failed before authentication.
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
request_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_error_log_at ON error_log (at DESC);
@@ -0,0 +1,101 @@
-- `tasks.created_by` stops cascading.
--
-- It was ON DELETE CASCADE, which made deleting an account delete every task that
-- account ever wrote — including in projects somebody else owns. Retiring a
-- colleague therefore removed their contributions from shared work rather than
-- leaving it behind. It is the last remaining way to lose other people's work by
-- accident, and the administrator's page saying so in plain words is a warning, not
-- a fix.
--
-- SET NULL instead: the task stays and loses its author. `created_by` becomes
-- nullable to make that expressible, which is the same shape `assignee_id` and
-- `completed_by` already have for the same reason.
--
-- SQLite cannot alter a foreign key action, so the table is rebuilt. The FTS
-- triggers come along: they are dropped first so that DROP TABLE and ALTER TABLE
-- see a schema they can parse, and recreated below. `tasks_fts` is an
-- external-content table keyed on `tasks.rowid`, and a rebuilt table hands out new
-- rowids, so the index is rebuilt at the end rather than left pointing at rows that
-- have moved.
--
-- verdande:rebuild-tables
DROP TRIGGER tasks_fts_insert;
DROP TRIGGER tasks_fts_delete;
DROP TRIGGER tasks_fts_update;
CREATE TABLE tasks_rebuilt (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
content TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 4,
due_date TEXT,
due_datetime INTEGER,
due_timezone TEXT,
duration_min INTEGER,
recurrence_rule TEXT,
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
-- Nullable, and SET NULL: an account can go without its work going with it.
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
completed_at INTEGER,
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
fold TEXT GENERATED ALWAYS AS (
replace(replace(replace(replace(replace(replace(
content || ' ' || description,
'ø', 'o'), 'Ø', 'o'),
'æ', 'ae'), 'Æ', 'ae'),
'å', 'aa'), 'Å', 'aa')
) STORED,
CHECK (priority BETWEEN 1 AND 4)
);
INSERT INTO tasks_rebuilt
(id, project_id, section_id, parent_id, content, description, priority,
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
assignee_id, created_by, completed_at, completed_by, sort_order,
created_at, updated_at, deleted_at)
SELECT
id, project_id, section_id, parent_id, content, description, priority,
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
assignee_id, created_by, completed_at, completed_by, sort_order,
created_at, updated_at, deleted_at
FROM tasks;
DROP TABLE tasks;
ALTER TABLE tasks_rebuilt RENAME TO tasks;
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
WHERE deleted_at IS NULL AND completed_at IS NULL;
CREATE INDEX idx_tasks_due ON tasks (due_date)
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
END;
CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
END;
CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
END;
-- The rowids the index was built against are gone with the old table.
INSERT INTO tasks_fts (tasks_fts) VALUES ('rebuild');

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