Compare commits

...
170 Commits
Author SHA1 Message Date
Kristian Wind f68d6a8c15 Merge pull request #80 from kristianwind/billeder-kan-ses-og-kopieres
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 12m56s
CI / Docker image builds (push) Successful in 2m29s
CI / Go (push) Failing after 15m40s
Release / Build and publish image (push) Failing after 7s
feat(noter): et indsat billede kan ses helt og kopieres videre
2026-09-14 22:04:38 +02:00
kristianwindandClaude Opus 5 ff0447317b feat(noter): et indsat billede kan ses helt og kopieres videre
I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
ikke ser ud som et. Der var ingen vej fra det til billedet selv.

Et klik åbner det nu i fuld størrelse, med en knap, der kopierer det, og et link,
der åbner det i sit eget faneblad. Escape lukker og sætter markøren tilbage i
teksten — ellers står fokus på en knap, der ikke findes længere, og den næste
tast går ingen steder.

Klikket ligger i onEditorClick ved siden af notelinket, som er det andet, man kan
klikke på inde i et felt, man ellers skriver i. Billedet bliver stadig markeret
af klikket, og det skal det: det er sådan, det slettes.

To ting, der ikke er til at gætte:

Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon er
en JPEG. Så et billede, der ikke allerede er PNG, tegnes om over et lærred —
ellers lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan
indsætte. Prøven indsætter derfor med vilje en JPEG; med en PNG var den kørt
forbi netop den kode.

Og løftet gives videre til ClipboardItem frem for at blive ventet af først.
Safari kræver, at clipboard.write kaldes i selve klikket, og en hentning
imellem gør klikket til fortid.

Ruden ligger uden for .wrap: editoren har sin egen rulning og sin egen
stablingskontekst, og et lag inde i den ville blive klippet af netop det ark, det
skal lægge sig over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 21:54:48 +02:00
Kristian Wind a2f6e9bda3 Merge pull request #79 from kristianwind/soegningen-foelger-med
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 10m33s
CI / Docker image builds (push) Successful in 2m1s
CI / Go (push) Failing after 14m48s
fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
2026-09-14 11:19:56 +02:00
kristianwindandClaude Opus 5 1bbd36c79b fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
lukket.

Feltet i selve notelisten har filtreret hele tiden, og det er prøvet to steder.
Men den søgning, man finder, er den store i toppen, og den er palettens. Så det
er ikke "søgning i noter virker ikke" — det er, at den søgning, man bruger, ikke
efterlod noget.

Adressen bærer nu søgningen. ⌘K sender `?note=…&q=…`, notesiden læser begge, og
listen ved siden af den åbne note er de andre træffere. Den skrives tilbage, når
feltet ændres, så adressen kan gemmes og deles — og frem for alt ikke lyver, når
feltet er tømt.

To effekter, der deler en markør, og hvorfor de læser den med `untrack`:

Den nederste sætter `settled` og kalder `replaceState`. Det vækker den øverste,
og på det tidspunkt har `$page.url` endnu ikke skiftet — så den læste den gamle
adresse, fandt en søgning, der ikke var enig med markøren, og skrev den tilbage
i feltet. Man tømte søgefeltet, og ordet kom igen. Hver effekt skal reagere på
sin egen kilde, adressen og feltet, og aldrig på den fælles markør.

Den fejl fandt jeg kun, fordi prøven fejlede på den sidste linje og
fejlbilledets tilgængelighedstræ stadig viste "Fyrreskov" i feltet. En prøve,
der kun havde tjekket adressen, havde været grøn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:10:09 +02:00
Kristian Wind 54359f8d68 Merge pull request #78 from kristianwind/tagbeskeden-naar-frem
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m46s
CI / Docker image builds (push) Successful in 1m21s
CI / Go (push) Failing after 13m54s
fix(release): tagbeskeden når frem til udgivelsen
2026-09-14 08:31:10 +02:00
kristianwindandClaude Opus 5 f400705bb7 fix(release): tagbeskeden når frem til udgivelsen
Den skulle have gjort det siden #72, og gjorde det ikke. v0.45.0 og v0.46.0 fik
begge flettecommittens overskrift som udgivelsestekst — uden at noget fejlede,
og uden at nogen kunne se det andet end på siden bagefter.

`fetch-depth: 0` er ikke nok. actions/checkout henter først alle tags korrekt og
overskriver derefter netop den ene, der udløste kørslen:

    git rev-parse refs/tags/v0.46.0
    git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0

Den henter commit-SHA'en ind i tag-refen. Den annoterede tag bliver dermed til
en letvægtstag, der peger direkte på commit'en, og `%(contents)` giver så
commitbeskeden. Kommentaren i filen sagde, at en overfladisk checkout var
problemet; det var rigtigt og utilstrækkeligt.

Tagget hentes derfor som *ref* igen, lige før det læses.

Og vagten: hver tagbesked begynder med sin egen version, så holder det ikke,
læser vi noget andet end tagget, og kørslen fejler frem for at udgive en forkert
tekst. Det er det, der manglede — fejlen var ikke svær at rette, den var svær at
opdage.

Målt i et klonet arbejdstræ i alle tre tilstande: annoteret giver den rigtige
tekst med selvlinket fjernet, letvægt giver flettecommitten og udløser vagten,
og en re-fetch af refen bringer den annoterede tilbage.

Teksten på de to udgivelser, der allerede stod forkert, er rettet i hånden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:21:27 +02:00
Kristian Wind 0dc4c2f9b8 Merge pull request #77 from kristianwind/besked-og-en-udgang
CI / OpenAPI is valid (push) Successful in 8s
CI / End-to-end (push) Successful in 8m56s
CI / Docker image builds (push) Successful in 1m39s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
feat(projekter): en deling siger til, og der er en vej ud af den
2026-09-14 08:17:53 +02:00
kristianwindandClaude Opus 5 7f09b35db5 feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men
det, spørgsmålet fik øje på, var rigtigt, og værre end det lød.

Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke
besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt
projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede,
uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag
ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv.

Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til
noget, der sker for en.

Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den,
der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles
allerede uden. Det, der manglede, var besked og en udgang.

`project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen
til projektet, så beskeden bærer sit eget mål og krævede ingen særregel.

Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller
nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en
andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du
ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget
projekt; det ville være en overdragelse, og den findes ikke endnu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 08:07:46 +02:00
Kristian Wind 0f4048d8f2 Merge pull request #76 from kristianwind/del-projekt-med-en-der-er-her
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m53s
CI / Docker image builds (push) Successful in 1m39s
CI / Go (push) Failing after 14m7s
feat(projekter): del et projekt med en, der allerede er her
2026-09-13 18:00:04 +02:00
kristianwindandClaude Opus 5 9b21b3eac5 feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.

`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.

De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.

Tre ting, der kostede en kørsel hver:

`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.

To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.

Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:52:03 +02:00
Kristian Wind a39f655565 Merge pull request #75 from kristianwind/soeg-i-indstillinger
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m38s
CI / Docker image builds (push) Successful in 1m38s
CI / Go (push) Failing after 14m6s
Release / Build and publish image (push) Failing after 4s
feat(indstillinger): en indstilling kan findes ved at søge efter den
2026-09-12 10:04:17 +02:00
kristianwindandClaude Opus 5 7e981354c2 feat(indstillinger): en indstilling kan findes ved at søge efter den
Indstillingerne er ti faner med syvogtredive afsnit, og den eneste vej til et
bestemt af dem var at huske, hvilken fane det lå under. ⌘K kunne opgaver,
projekter og noter — alt det, man selv har skrevet, og intet af det,
programmet består af.

Ét indeks, to steder det bruges: et felt øverst i indstillingerne og paletten.
To lister over de samme afsnit ville være to steder at glemme et nyt et.

Overskrifterne står ikke i indekset. Hver post peger på den nøgle, afsnittets
egen overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af
sig selv, og søgningen kan begge sprog uden at kende nogen af dem.

`words` er derimod skrevet i indekset, og det er med vilje: det er søgeord,
ingen får at se. Det danske afsnit hedder "Tælling af installationer", men det
ord, man kender det ved, er "beacon" — og på samme måde 2fa, imap, webcal,
webhook. De er de samme på begge sprog, hvilket er hele grunden til, at de
virker som søgeord.

Ankrene er nye id'er på afsnittene. Tre af dem stødte sammen med et felt inde i
afsnittet — to ens id'er gør <label for> tvetydig og getElementById tilfældig —
og hedder derfor to-faktor, kalenderfeed og caldav-server.

Springet dertil kan ikke overlades til browseren: siden hentes af ruteren, og
afsnittet findes først, når dens data er hjemme. Der prøves i to sekunder og
gives så op. Blinket bagefter er ikke pynt — man er landet midt i en side med
otte afsnit, man ikke selv rullede hen til.

Prøverne læser filerne frem for at klikke: halvdelen af afsnittene vises kun
under en betingelse, så en prøve, der ledte efter dem i en browser, ville melde
fejl på det, der med rette var skjult.

Og palettens pladsholder sagde "Søg i opgaver og projekter". Den har kunnet
noter længe, og kan nu også indstillinger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:56:00 +02:00
Kristian Wind 1f0a43ac90 Merge pull request #74 from kristianwind/kalender-url-ud-over-kanten
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m13s
CI / Docker image builds (push) Successful in 1m30s
CI / Go (push) Failing after 13m56s
fix: URL'er kan læses helt på en telefon
2026-09-12 09:43:40 +02:00
kristianwindandClaude Opus 5 61f8887319 fix(integrationer): en adresse kan læses helt, også på en telefon
Feed'et, mailadressen, krogen, curl-eksemplet, CalDAV-serveren og de to
redirect-URI'er lå i skrivebeskyttede <input>. Et input ombryder ikke — det
ruller indeni — så på en telefon stoppede adressen midt i kassen, og resten
fandtes kun ved at trække inde i feltet. Der var ikke engang et rullepanel at
se: feltet så ud som om det indeholdt det, der stod i det. Målt ved 390 px var
kassen 306 px og krogens adresse 634.

De er nu <output>. Det er stadig et felt, en <label for> kan pege på, så
navnene på felterne bliver ved med at høre sammen med dem, og det er tekst,
der ombryder. `user-select: all` beholder det ene, inputtet gjorde bedre: ét
tryk tager hele adressen.

Det samme gjaldt invitationslinket i en delt note, som vises netop når der
ikke er nogen post at sende det med — altså når det er det eneste, der findes.

Prøven nævner felterne ved navn frem for at forespørge på en klasse. Den, der
måler `.field output` og finder nul, består også den dag felterne er lavet om
til noget andet klippet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:18:49 +02:00
kristianwindandClaude Opus 5 72637a153a fix(kalender): en abonnementsadresse skubber ikke siden ud over skærmen
Adressen på et kalenderabonnement blev vist i en punktliste uden noget, der
lod den ombryde. En URL er ét ord på et par hundrede tegn uden et mellemrum i,
så den kan ikke bryde af sig selv — og det bredeste element på en side
bestemmer sidens bredde. På en telefon betød det, at hele indholdsspalten fik
et vandret rullepanel, ikke bare den ene linje.

`overflow-wrap: anywhere` er det, resten af indstillingerne allerede bruger til
et token og en hemmelighed; listen her var den, der blev glemt. Samtidig får
den layout frem for at ligge som løse inline-spans: navn, adresse og knap
under hinanden, uden punkttegnets indrykning.

Prøven måler `.content` og ikke dokumentet. Skallen holder selv siden på
skærmens bredde, så en måling på documentElement består med fejlen i behold —
den blev skrevet først og bestod, hvilket er grunden til at kommentaren står
der.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 09:11:26 +02:00
Kristian Wind f06a25b43e Merge pull request #73 from kristianwind/dagens-plan-kan-ses
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Failing after 10m29s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
feat(plan): dagens plan står på I dag, og ikke kun i en besked
2026-09-09 10:32:28 +02:00
kristianwindandClaude Opus 5 6cf184caa6 feat(plan): dagens plan står på I dag, og ikke kun i en besked
Planen var en notifikation og ikke andet. Klokken klippede den af efter
én linje — "2 forfalder i dag, 1 er sprun…" — og klikket førte ingen
steder hen, fordi en plan hverken er en note, en opgave eller et projekt.
Det eneste sted, den landede hel, var push-beskeden på telefonen.

Nu står den som et kort øverst på I dag, dér hvor man kigger, når man
starter dagen. Beskeden er påmindelsen; kortet er stedet, den bliver.

Teksten er gemt for dagen frem for regnet forfra, hver gang nogen kigger.
En model pr. sidevisning er dyr for noget, der ikke har ændret sig — og
vigtigere: en plan, der siger noget andet, når man kigger på den, end den
sagde, da den kom, er værre end begge dele hver for sig. Kortet siger
også, hvornår den blev lavet: en plan fra klokken syv er en anden slags
oplysning klokken fire end klokken otte.

En plan fra i går sendes ikke med. Et kort, der viser gårsdagens plan
under overskriften "i dag", er værre end et tomt kort.

"Forny planen" laver en ny, når dagen har flyttet sig, og sender ikke en
besked om noget, man står og kigger på — den slags lærer folk at ignorere
klokken. Og for den, der har morgenbeskeden slået fra, tilbyder kortet at
lave planen på stedet, så funktionen ikke kun findes for dem, der vil
vækkes.

Klokken fører nu til I dag, og planen fylder de tre linjer, den er, i
stedet for at blive klippet af.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:27 +02:00
Kristian Wind 3c14906db2 Merge pull request #72 from kristianwind/dokumenter-det-der-blev-bygget
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m44s
CI / Docker image builds (push) Successful in 1m50s
CI / Go (push) Failing after 14m28s
docs: skriv det ned, der er blevet bygget
2026-09-09 09:02:58 +02:00
kristianwindandClaude Opus 5 70d7398d5a docs: skriv det ned, der er blevet bygget
API-dokumentationen har fulgt med hele vejen — der er en prøve, der fejler,
hvis en rute ikke står i openapi.yaml, og den fangede mig fire gange i går.
Brugervejledningen har ikke fulgt med, og det er den, folk læser.

Nyt: en side om beskeder — hvad der udløser en, hvorfor gentagne
rettelser bliver slået sammen, push pr. apparat og mærket på ikonet. En
om at åbne links i en anden browser, som mest af alt handler om, hvad en
webside ikke kan: hverken se hvilke browsere der findes eller vælge
mellem dem. Og en om tællingen af installationer, som ikke stod nogen
steder overhovedet — en melding, der er slået til som udgangspunkt, har
en pligt til at være skrevet ned, også når ingen har spurgt.

Rettet: AI-siden beskrev to funktioner, hvor der nu er syv, og
listen over hvad der sendes hvorhen var derfor forkert som en påstand om
noget privat. Noter-siden manglede deling med en e-mailadresse og hvad
`@Sofie` gør. Mail-siden manglede den anden vej ind i indbakken.
Konfigurationen sagde stadig, at versionstjekket kun kunne se efter.

Og tagbeskeden når nu frem. Den ER annonceringen — skrevet på produktets
eget sprog til dem, der bruger det — og udgivelsen på GitHub blev fyldt
med en autogenereret liste over commit-overskrifter i stedet, som er
skrevet til dem, der læser koden. Nu står teksten øverst og listen under
den. Linjen med linket til udgivelsen selv tages af: en side, der linker
til sig selv, hjælper ingen.

Dokumentationen bygges nu også på en PR. `mkdocs build --strict` fejler
på et link til en side, der ikke findes, og at bygge først efter merge er
at få det at vide på main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:52:54 +02:00
Kristian Wind 1cabbb7a78 Merge pull request #71 from kristianwind/hvilken-browser-aabner-linket
feat(links): vælg hvilken browser et link åbner i
2026-09-09 08:50:30 +02:00
kristianwindandClaude Opus 5 5eb689e4c2 feat(links): vælg hvilken browser et link åbner i
Ønsket var en liste over de installerede browsere. Den findes ikke: en
webside kan hverken se, hvilke browsere der er på maskinen — listen er
lukket land, fordi den er et fingeraftryk, der ville følge folk rundt på
nettet — eller bede systemet om at bruge en bestemt af dem.

Det eneste håndtag er de URL-skemaer, browserne selv registrerer:
firefox://open-url, googlechrome://, microsoft-edge:. Dem kan man prøve,
og man kan ikke spørge, om de findes. Så indstillingen er skrevet som
det, den er — et forsøg med et faldback — frem for som et løfte, der
ville holde på telefonen og ikke på en Mac.

Valget står i localStorage, ikke på kontoen. Samme grund som
sidebjælkens bredde: det er en egenskab ved maskinen, man sidder ved.
firefox:// virker på telefonen og gør ingenting på Mac'en, så et valg,
der fulgte kontoen, ville være forkert det ene af de to steder hver gang.

Ét sted i koden frem for ét pr. komponent. Links står i opgavetitler, i
beskrivelser, i noter og i indstillingerne, og en regel, der skulle
gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
Kun udadgående links; programmets egne ruter går gennem SvelteKit som før.

To ting, målingen rettede undervejs:

Det skjulte iframe — den kendte opskrift — bliver blokeret af appens egen
Content-Security-Policy, som siger default-src 'self'. Uden e2e-prøven,
der fangede CSP-fejlen i konsollen, ville skemaet aldrig være blevet
forsøgt i produktion, og indstillingen ville have set ud til at virke,
fordi faldbacket åbnede linket alligevel. Nu sættes location i stedet:
et ukendt skema afbryder navigationen frem for at forlade siden.

Og faldbacket troede sig blokeret hver eneste gang. window.open med
noopener returnerer altid null — det står i specifikationen — så
kontrollen af, om fanen blev åbnet, faldt altid ud til "blokeret", og
den aktuelle fane blev navigeret væk i stedet. Håndtaget er det eneste,
der kan kende en blokeret fane fra en åbnet, så det bruges nu, og opener
ryddes bagefter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:41:21 +02:00
Kristian Wind e931ddc313 Merge pull request #70 from kristianwind/ny-version-siger-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m50s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
Release / Build and publish image (push) Failing after 5s
feat(opdatering): en ny version siger til, i stedet for at vente på at blive fundet
2026-09-08 17:05:52 +02:00
kristianwindandClaude Opus 5 2210dc082a feat(opdatering): en ny version siger til, i stedet for at vente på at
blive fundet

Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på
indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis
man i forvejen havde mistanke om, at der var noget at se efter — og "der
er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter
sig til at kigge efter.

Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der
er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder,
frem for ud af programmet.

Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
samme er den slags besked, folk slår fra — og så er den næste, der
betyder noget, også slået fra. Versionen, der er sagt til om, skrives
først, når beskederne er sendt: den anden vej rundt ville en fejl
midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.

Kun administratorer, af samme grund som versionsfeltet allerede er det:
en besked om, at serveren er bagud, er en besked om en andens arbejde,
hvis man ikke selv kan gøre noget ved den.

Og et tjek, operatøren har slået fra, bliver ikke til en besked ad
bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:55:53 +02:00
Kristian Wind 9fa47822c1 Merge pull request #69 from kristianwind/ai-dagens-plan-2
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m57s
CI / Docker image builds (push) Successful in 2m19s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m3s
feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
2026-09-08 15:15:48 +02:00
Kristian Wind 366bed3a15 Merge pull request #68 from kristianwind/ai-foreslaar-du-bestemmer
feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
2026-09-08 15:06:21 +02:00
Kristian Wind 07fc170b91 Merge pull request #67 from kristianwind/beacon-siger-det-hoejt
feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
2026-09-08 15:06:17 +02:00
kristianwindandClaude Opus 5 8139003b35 feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
De tre sidste af de fem. Alle tre samme sted hen: det, man ellers selv
skulle sidde og gøre klokken syv om morgenen.

Dagens plan. Én besked om morgenen om det, der forfalder i dag, og det,
der er sprunget over. Tallene tælles i basen og gives til modellen som
kendsgerninger, den ikke må regne om — en plan, der siger fire opgaver,
hvor der er tre, er værre end ingen plan. Er der ingen model, står
tallene alene, og det er stadig det, man havde brug for at vide. Er der
hverken noget, der forfalder, eller noget, der er sprunget over, kommer
der ingen besked: en morgen uden noget at gøre er en god morgen, og en
besked om ingenting lærer folk at lade være med at åbne beskeder.

Timen er personens egen — klokken syv er syv forskellige steder — og
jobbet kører hver time frem for på et klokkeslæt, så en instans, der sov,
ikke springer dagen over. Flytter man timen, ryger dagens mærke, så man
kan se virkningen i dag i stedet for i morgen.

Mails, der giver mening. Opgaven hed, hvad mailen hed: "Anders Jensen:
SV: SV: Vedr. levering uge 12". Nok til at genkende, ikke nok til at
gøre noget ved. Nu skriver modellen den handling, mailen beder om, og
lader være, hvis den ikke beder om nogen. Ét kald pr. gennemløb frem for
ét pr. mail — femogtyve mails er ellers femogtyve gange ventetid — og
emnet bliver stående i beskrivelsen, så en misforstået mail stadig kan
læses. Falder modellen ud, hedder opgaven afsender og emne som før: en
model, der er nede, må ikke kunne stoppe posten.

Og svar på et spørgsmål. "Hvad lovede jeg Anders i august?" — svaret står
spredt over tre noter, og ingen af dem indeholder ordet "lovede".
Søgningen kræver, at alle ord optræder, hvilket er rigtigt for et
søgefelt og forkert for et spørgsmål, så kandidaterne hentes ord for ord
og de noter, flere ord rammer, kommer øverst. Modellen får kun dem: den
slår ikke selv noget op, så et svar kan ikke handle om nogen andens
noter. Og svaret bærer, hvad det står på — en model, der siger "det
gjorde du den 14.", er kun brugbar, hvis man kan slå op i det, den læste
det i.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:05:36 +02:00
kristianwindandClaude Opus 5 96e1fa251b fix(beacon): beskeden skubber i stedet for at dække
Den lå som en rude i hjørnet, oven på arbejdet, og dækkede knapperne i
notens fod og i delepanelet — seks e2e-prøver klikkede forgæves på noget,
der lå under den.

Det var ikke kun et prøveproblem. En besked, der er i vejen, bliver
klikket væk uden at blive læst, og så er den uopfordrede oplysning en
formalitet frem for noget, nogen har set. Nu står den øverst i indholdet
og skubber siden ned, indtil den bliver besvaret.

Og e2e beviser den nu frem for at klikke den væk: den første konto skal
møde beskeden, og der skal stå et instance_id og en version i den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:47 +02:00
kristianwindandClaude Opus 5 40102d6038 feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
To steder, hvor arbejdet ligger og venter på at blive skrevet færdigt.

Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive
dem ordentligt — og derfor også der, hvor de bliver liggende. At åbne
hver enkelt og sætte projekt, dato og prioritet er ti klik pr. linje. Nu
er det ét spørgsmål og et ja pr. linje.

Og et referat er fuldt af sætninger, der ligner opgaver, og af tre, der
er det. Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig
på, og om at citere de ord, den tog dem fra — så et forkert punkt kan
gennemskues frem for bare at være forkert.

Modellen skriver ikke noget. Den foreslår en *linje* i quick-add-syntaks,
og forslaget bliver til noget, når nogen siger ja. Det er ikke
forsigtighed for forsigtighedens skyld: en model, der retter i folks
opgaver direkte, skal have ret hver gang for at være til gavn, hvor en,
der foreslår, skal have ret tit nok til at spare et par tastetryk.

Linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
findes. Der er én fortolkning af "i morgen" i dette program, og forslaget
går gennem den samme som alt andet — også når linjen er blevet rettet i
hånden, inden den blev accepteret. Og et forslag, skrevet i det sprog man
selv skriver i, kan læses og rettes af den, det bliver stillet til.

Modellen får listen over de projekter, der findes, og må ikke nævne
andre. Et `#Nyt projekt`, den fandt på, ville lave et projekt ved et
uheld, første gang nogen sagde ja — og et gæt må ikke kunne flytte en
opgave hen, hvor ejeren ikke leder efter den. Testet.

Forslagene til indbakken bærer et nummer tilbage til den opgave, de
handler om. Rækkefølgen alene ville også kunne bruges og gør det som
reserve, men en model, der springer en opgave over, ville så flytte alle
de følgende forslag én plads — og "sæt en dato på den her" ville lande på
en anden.

Noten bliver ikke rørt, når der laves opgaver ud af den. Det er
fristende at skrive linket ind i den, så de to peger på hinanden, men en
tekst, et program skriver i uden at blive bedt om det, er en tekst, man
holder op med at stole på. Opgaven bærer i stedet, hvor den kom fra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:48:56 +02:00
kristianwindandClaude Opus 5 0cf5c8c281 feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
Beaconet er slået til som udgangspunkt. Det kan kun forsvares, hvis det
bliver sagt af sig selv — og det blev det ikke. Det stod rigtigt og
fyldestgørende under Indstillinger → Data, som er en side, der bliver
åbnet af dem, der i forvejen har en mistanke om, at der er noget at læse.
For alle andre var "du kan slå den fra" et tilbud til nogen, der ikke
vidste, der var noget at slå fra.

Nu møder en administrator beskeden første gang: hvad der bliver sendt,
med de rigtige værdier i — det rigtige id, den rigtige version, den
rigtige adresse — og to knapper. Værdierne står der frem for ordet
"anonymt", fordi et løfte om telemetri er præcis så meget værd som
læserens mulighed for at efterprøve det.

`keep` er en peger og ikke en bool. En tom krop måtte ikke kunne betyde
"slå det fra": et fravalg, ingen har truffet, er lige så meget et brudt
løfte som et tilvalg, ingen har truffet.

Og tre ting mere, som Yggdrasil har lært på den hårde måde:

Et beacon, der ikke kan nå sin collector, fejlede i fuldstændig tavshed.
Det prøvede igen i morgen, siden sagde "sidst sendt" med en dato, der
bare blev ældre, og ingen opdagede det. Grunden bliver nu skrevet ned og
vist.

En installation, der opdaterer sig selv om natten, meldte gårsdagens
version i op til et døgn, fordi døgnet blev talt fra sidste vellykkede
melding. En version, der er skiftet, sender nu med det samme.

Og det offentliggjorte tal har fået et gulv. Et tal, der falder, kan ikke
offentliggøres som en kendsgerning — en dårlig udrulning, en
DNS-ændring, der stille stopper meldingerne, og rækker, der er væk, ser
alle sammen ud som "færre bruger det nu".

Adressen bliver stående: Verdande melder til verdande.yggdrasilpanel.com,
ikke til panelets egen collector. De to tæller hver sin slags
installation, og en fælles tabel har intet felt til at holde dem ude fra
hinanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:39:14 +02:00
Kristian Wind 445d9de57a Merge pull request #66 from kristianwind/indbakken-har-en-adresse-apps-kan-skubbe-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 11m3s
CI / Docker image builds (push) Successful in 2m17s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 15m6s
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
2026-09-08 14:12:17 +02:00
Kristian Wind d5ad459eba Merge pull request #65 from kristianwind/maerket-paa-ikonet
feat(beskeder): appens ikon bærer tallet på ulæste
2026-09-08 14:04:12 +02:00
Kristian Wind c297d256e4 Merge pull request #64 from kristianwind/tag-en-person-i-en-note
feat(noter): nævn en person i en note, og den er delt med dem
2026-09-08 14:04:08 +02:00
kristianwindandClaude Opus 5 d063425cae feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
Der var to veje ind i forvejen, og ingen af dem passer til "gem den her,
mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt
og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når
nogen har sat en mailserver op, og går gennem en indbakke, der kan være
minutter om det. Det, der manglede, var en URL, man kan smide en linje
tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan
kalde en URL.

Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en
genvej, der bare sender det, man har markeret. Første linje bliver til
opgaven, resten til noten under den. Linjen læses med quick-add-parseren,
så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i
feltet øverst i fladen — dét er hele pointen med at have én parser: den
vej, teksten kom ind ad, ændrer ikke, hvad den betyder.

Sit eget token frem for at låne mailens. De to er den samme slags
hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en
genvej på en telefon, der blev væk, skal kunne skiftes uden at
mailadressen — som står i andres adressebøger — skifter med.

Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den
persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge
i et script på en anden maskine, og en, man ikke kan.

Kun POST. En GET ville være nemmere at kalde, og det er præcis
problemet: forudindlæsning i en browser, en linkkontrol i en chat og en
historik, der bliver besøgt igen, ville alle lave opgaver, ingen har
bedt om. Sin egen kvote frem for postens, så en travl integration ikke
kan lukke for den indgående post.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:03:32 +02:00
Kristian Wind 0764fbd566 Merge pull request #63 from kristianwind/del-note-med-en-uden-konto
feat(noter): del en note med en, der ikke har en konto endnu
2026-09-08 12:13:34 +02:00
kristianwindandClaude Opus 5 b041b32aa2 feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på
fanen. Det halve af pointen med at få besked er at få den, mens man laver
noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget
ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc.

Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og
kender tallet, og service-workeren, når den ikke er — og det er netop
dér, mærket er det, der siger, at der er sket noget.

Tallet kommer med i push-beskeden frem for at blive talt op af
service-workeren. Den husker ingenting mellem to opvågninger og kunne
derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller
sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en
tælling, der fejler, udelader feltet frem for at sende et nul — så står
mærket, som det stod, i stedet for at blive tørret af en optælling, der
ikke lykkedes.

Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går
ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud —
det står i Dock'en, også når ingen er logget ind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:12:54 +02:00
kristianwindandClaude Opus 5 d8f5f6e236 feat(noter): nævn en person i en note, og den er delt med dem
At skrive nogens navn i en note *er* at ville have dem til at læse den.
Indtil nu var det to handlinger — skriv navnet, åbn panelet, find
personen, del — og den anden blev glemt, så noten stod med en besked til
en, der ikke kunne se den.

Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem.
Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter.

Slået op i navnene frem for tolket ud af teksten. Et navn kan have
mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også
fange sætningen bagefter: `@Sofie i morgen` ville blive til en person,
der hed noget i den retning. Her er der ikke noget at gætte — der står et
navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at
dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte
at læse. En mailadresse er ikke en omtale, det længste navn vinder, og
ejefaldets s hører til sætningen.

Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som
links: en note gemmes ved hver tastepause, så uden sammenligningen var
hver gemning en ny deling og en ny besked om det samme navn.

Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke
give den videre — ellers kunne den, ejeren har delt med, skrive et navn
og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er
den samme regel, panelet følger.

Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste,
der opfylder sætningen, er det rigtige at give. En rolle, ejeren har
hævet i panelet, sænkes ikke igen af en omtale.

Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte
dig" og ikke også "rettede en note" om den samme gemning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:08:12 +02:00
Kristian Wind 61634a6bb5 Merge pull request #62 from kristianwind/noter-og-opgaver-giver-besked
feat(beskeder): klokken siger til, når nogen rører noget, der er dit
2026-09-08 11:59:04 +02:00
kristianwindandClaude Opus 5 8ee9fbedfb feat(noter): del en note med en, der ikke har en konto endnu
En note kunne deles med dem, der allerede var på instansen — og kun med
dem. Skulle en anden læse med, skulle man først invitere dem ind, vente
på at de oprettede sig, finde noten frem og så dele den. Fire skridt og
en ventetid for det, der er én sætning: lad Sofie se den her.

Nu er der ét felt. Man skriver adressen, og serveren ved, om der er en
konto bag: er der det, bliver det en deling med det samme, og ellers en
invitation, der bliver til delingen i samme øjeblik, kontoen oprettes.
Hvem der har en konto, er ikke noget, man skal vide for at dele en note.

Rækken er den, der allerede fandtes. En invitation bar i forvejen en
e-mail, en rolle og et forseglet token; det eneste, der manglede, var at
kunne sige, hvad den var en invitation *til*. note_id ved siden af
project_id, med fremmednøglen — dén, der rydder invitationen væk, når
noten slettes, inden nogen når at tage imod en invitation til noget, der
ikke er der længere.

Det er ikke ny magt til nogen: enhver, der ejer et projekt, kunne i
forvejen invitere en fremmed ind på instansen pr. e-mail. Kun ejeren kan
invitere til en note, som kun ejeren kan dele den.

De inviterede står i panelet ved siden af dem, der er kommet, og kan
trækkes tilbage. Uden det er en invitation sendt i går usynlig, og så
sender man den igen — endnu et link til den samme indbakke, og kun det
ene af dem til at gøre ugyldigt.

Og delingen siger selv til: den, der får en note delt, får besked om, at
den er der. Klokken sorterer nu på id efter tid, fordi tiden er sekunder
— en note, der bliver delt og rettet i samme håndbevægelse, stod ellers
i den rækkefølge, basen nu engang fandt dem i.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:58:47 +02:00
kristianwindandClaude Opus 5 ab77024ec9 feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne
den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved
det, man deler, skal man kunne se uden at lede efter det.

Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt
med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke
på serveren, så et engelsk interface får en engelsk klokke.

Beskeder om den samme note bliver slået sammen, mens de er ulæste. En
note gemmes på en tastepause, så en række pr. gem ville lave én person,
der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er
den næste rettelse en ny ting at få at vide — ikke en gentagelse.

note_id får sin egen søjle ved siden af project_id og task_id frem for et
generisk (kind, id)-par: de tre er ikke det samme slags nul, og
fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et
generisk par ikke have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:40:43 +02:00
Kristian Wind 48509e3073 Merge pull request #61 from kristianwind/delte-noter-tager-links-med
Release / Build and publish image (push) Failing after 8s
feat(noter): en delt note tager de noter med, den peger på
2026-09-07 22:59:47 +02:00
kristianwindandClaude Opus 5 916a0c0224 feat(noter): en delt note tager de noter med, den peger på
Den anden halvdel af den melding, der begyndte det her: linkene i en delt note
førte ingen steder hen, fordi det, de pegede på, ikke var delt. At dele en note
er at dele det, den *siger*, og et link er en del af det.

note_shares får en `linked`-søjle. 0 er en deling, nogen har valgt; 1 er en, der
er udledt af et link. Skellet er hele modellen. Fladen kan sige *hvorfor* en
person har en note — "fulgte med" er en anden sætning end "du delte den" — og de
udledte rækker kan regnes forfra efter hver ændring, uden at røre dem, ejeren
selv har sat.

SyncLinkedShares regner billedet om fra bunden efter enhver af ejerens egne
handlinger: en deling, en fjernet deling, en gemning der har ændret links eller
titel, en sletning. Hele kæden følger med, ikke kun første led, med et loft på
hundrede noter — "del denne ene note" må ikke kunne blive til fire hundrede.
Rollen arves fra den note, man kom fra, og stærkest vinder, når en note kan nås
ad to veje.

Kun ejerens egne noter følger med, og det er ikke en optimering: et link peger
på en titel, og en medredaktør må skrive titler. Kunne et link hente hvad som
helst, kunne den, man har delt med, skrive [[Bankoplysninger]] og læse med.
Derfor slås der kun op blandt ejerens egne noter, og derfor regner kun ejerens
egne gemninger om. Begge dele har en Go-prøve.

Det lukker ikke hullet helt: skriver medredaktøren linket, og gemmer ejeren
bagefter sin egen note, følger den note med dér. Det, der står imod, er
synlighed — linket står som tekst i ejerens egen note, og panelet viser hver
note, der er fulgt med, og hvem der har den.

Udregningen åbner hver af ejerens noter, fordi titlerne er forseglet. Derfor to
værn: har man ikke delt noget, svares der med det samme ud fra ét EXISTS, og en
gemning regner kun om, når links eller titel faktisk har flyttet sig. Uden det
første kostede "Ny note" halvfems millisekunder, og i det hul nåede e2e-prøven
for monospace at taste sin titel ind i den forrige note, som editoren så tegnede
væk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 22:28:58 +02:00
Kristian Wind ff75302cf8 Merge pull request #60 from kristianwind/note-links-og-kodeknap
fix(noter): følg note-links i en delt note, og lad </> være kode
2026-09-07 21:58:29 +02:00
kristianwindandClaude Opus 5 08d703e3af fix(noter): følg note-links i en delt note, og lad </> være kode
To ting meldt af en, der havde en note delt med sig.

Note-linkene i den delte note gjorde ingenting. Opslaget faldt tilbage på det
første søgeresultat, når ingen titel passede — og i en delt note er det første
resultat næsten altid noten selv, fordi den nævner den titel, den peger på. Så
åbnede linket den note, man allerede stod i. Kun en titel, der passer helt,
tæller nu; og rammer den forbi, siger beskeden den rigtige grund: en note, der
deles, deler ikke de noter, den peger på, så modtageren kan se linket uden at
kunne åbne det.

Og `</>` sad på kildevisningen med teksten "Vis som kode" på. Tegnet betyder
inline kode i ethvert andet program, så et markeret ord plus et tryk gav hele
noten som Markdown. Nu gør `</>` det, det siger — ⌘E, en kontakt, og ordet under
markøren når der ikke er markeret noget — og kilden har fået Markdown-mærket og
sin egen tekst.

Kode indsættes som en node frem for med execCommand: Chromium renser den HTML
gennem indsætningsvejen og gav et <span> med skrifttypen skrevet ind i sig, som
htmlToMarkdown ikke kan skrive ned som kode.

To e2e-prøver, som begge fejler på koden før den her.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 21:50:12 +02:00
Kristian Wind d4784fd174 Merge pull request #59 from kristianwind/upcoming-horizon
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 9m37s
CI / Docker image builds (push) Successful in 2m9s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 14m45s
Kommende: en horisont på 7, 14 eller 30 dage
2026-09-07 15:08:06 +02:00
kristianwindandClaude Opus 5 094ea5cdd8 feat(upcoming): look up to 30 days ahead, and choose how far
Kommendes liste has always been the next seven days and nothing else. Seven days
is the right walk to take in the morning, and it is still what the view opens on
— but "when does this month get busy" is a different question, and the month grid
answers it with shape rather than in order. So the list now takes a horizon: 7,
14 or 30 days, chosen with a switcher beside the view buttons.

Only the list gets one. A week grid is a week and a month grid is a month; there
is no fortnight for them to show, so the switcher is hidden in those modes.

The choice lives in localStorage next to the mode, under its own key. It is the
same kind of choice as list-or-grid — how you want to look, not a fact about the
work — and it should survive a reload without following you to a phone where
thirty day headings is a very long scroll.

Once the strip is longer than a week the day headings carry the week number.
Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not
say which mandag.

Server side the endpoint already took `?days=` and capped it at 31, so the window
needed nothing — but its row limit was a constant 1000 written for a week. A
month asked for with a week's ceiling would truncate the last days silently, and
a day cut off the end of the answer looks exactly like a day with nothing due, so
the limit now follows the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
2026-09-07 14:46:49 +02:00
Kristian Wind f60758b770 Merge pull request #58 from kristianwind/site-all-themes
docs(site): show all six themes, and put the demo line in English
2026-09-04 09:56:28 +02:00
kristianwindandClaude Opus 4.8 9d390d021f docs(site): show all six themes, and put the demo line in English
Adds a gallery of the same Today view in each of the six themes — Northern lights,
Charcoal, Dusk, Daylight, Paper, Contrast — so the range is shown rather than
described, on the landing page and (as the swatch panel) the docs home. The lone
Charcoal Today capture is folded into it.

And the quick-add demo that leads the docs — "betal moms i morgen kl 10 p1 #Firma
@regnskab" — is now English, "file the VAT return tomorrow at 10am p1 #Accounts
@tax", on every page it appears as the line to type: the home, the quick-add page,
the API and MCP examples, and the OpenAPI blurb. The Danish date words on the
quick-add page stay, because documenting the Danish parser is their job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 09:46:50 +02:00
Kristian Wind 18db79c063 Merge pull request #57 from kristianwind/site-charcoal-features
docs(site): Charcoal theme, offline, and the star that comes off
2026-09-04 09:27:50 +02:00
kristianwindandClaude Opus 4.8 1612f53966 docs(site): the Charcoal theme, offline, and the star that comes off
Refreshes the whole screenshot set to the app as it ships now — the three-line
note rows, the lightened calendar — and adds two Charcoal captures, the theme
modelled on Apple Notes, since it is the one worth showing. The landing page and
the docs home both gain it, a "holds through a short network drop" line, and the
correction that a starred or flagged mail now loses its mark once it is a task
rather than keeping it. Six themes now, not five.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 09:20:11 +02:00
Kristian Wind 4d5f1a56b1 Merge pull request #56 from kristianwind/gmail-unstar
CI / OpenAPI is valid (push) Successful in 2m55s
CI / End-to-end (push) Successful in 8m3s
CI / Docker image builds (push) Successful in 1m46s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 13m23s
feat(gmail): take the star off a mail once it has become a task
2026-09-04 09:06:31 +02:00
Kristian Wind 274e392708 Merge pull request #55 from kristianwind/offline-cache
feat(offline): hold through a short network drop with a caching worker
2026-09-04 08:57:38 +02:00
kristianwindandClaude Opus 4.8 84ca506961 feat(gmail): take the star off a mail once it has become a task
A starred Gmail message became a task and kept its star, so a starred inbox only
grew — the IMAP side has always unflagged what it imports, and Gmail could not,
because the scope was gmail.readonly. This gives Gmail the same one write: after
the task is made, the STARRED label comes off the message, in one batchModify for
the whole sweep. Nothing else is touched — not the body, not other labels, and a
label trigger's label stays where it is, since that label is the person's own.

The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope
Google offers that can remove a label. An existing connection keeps working on its
old read-only grant — the sync still reads, the unstar is a best-effort 403 that is
logged, not fatal — and starts clearing stars once the account is reconnected and
the new grant is in place. It self-heals: a mail that could not be unstarred stays
starred, stays in the next query, and is tried again.

The consent screen now asks for more than it used to; the settings copy, the docs
and the scope table all say modify rather than read-only in the same change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 08:57:07 +02:00
kristianwindandClaude Opus 4.8 6db61b0cc7 test(e2e): block the service worker except where the test is about it
The caching worker controls every page once it registers, and Playwright's
page.route() does not see the fetches a worker makes — so the failed-load test,
which forces a 500 to check the error path, got the real 200 through the worker
and read as a broken feature. Block the worker by default and turn it back on only
for the offline test, which is the one that needs a real one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 08:12:15 +02:00
kristianwindandClaude Opus 4.8 57689ddfbb feat(offline): hold through a short network drop with a caching worker
The service worker did Web Push and nothing else, with a note that a cache
serving yesterday's data was worse than an honest "cannot connect". This adds a
cache that does not do that: network-first for everything that can go stale, so a
reader online always gets the live answer and the cache is reached only when the
network cannot answer. The task and note lists stay live; they just no longer go
blank the moment the signal does — a reload through a tunnel, a lift or a
base-station handover comes back to the app instead of the browser's offline page.

Writes are untouched and still go straight to the network; api.js already retries
them across the same short drop. The shell cache is versioned so a deploy lands in
a fresh cache and the old one is dropped on activate; the data cache is cleared on
sign-out so the next person on the browser does not inherit it.

The old hand-registered /sw.js becomes SvelteKit's src/service-worker.js, which it
registers itself, and the push handlers move across unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 22:46:35 +02:00
Kristian Wind 7770ba9844 Merge pull request #54 from kristianwind/tasks-first-tap-rounded
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Successful in 11m2s
CI / Docker image builds (push) Successful in 2m40s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 15m27s
Rows: open on the first tap on iPhone, round the hover highlight
2026-09-03 22:23:08 +02:00
kristianwindandClaude Opus 4.8 55b8e438ad fix(rows): open on the first tap on iPhone, and round the hover highlight
On a touch screen a :hover on the tap target makes iOS spend the first tap
applying the hover and only open on the second — the double-tap Kristian hit
on tasks (and notes). The row hover is gated behind @media (hover: hover)
now, so a touch has no hover to apply and the first tap opens. The note
row's action buttons, which the hover used to reveal, are shown outright on
touch instead. And the task-row hover highlight is a rounded box (border
radius on the row), the same corner as a note in the list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 22:15:23 +02:00
Kristian Wind 1607a22f0c Merge pull request #53 from kristianwind/charcoal-separators
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m56s
CI / Docker image builds (push) Successful in 2m2s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 15m10s
Charcoal: lighter day separators in the note list
2026-09-03 20:54:56 +02:00
kristianwindandClaude Opus 4.8 8a5dfef41a fix(theme): lighter day separators in Charcoal
In Charcoal the note-list day separators used --ground, the darkest colour
in the theme, so they read as dark bars on the lighter list. They take the
menu's own resting grey (#2d3133) now — the colour "Projekter" sits on —
via a --note-group-bg fallback token, so every other theme keeps --ground.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 20:46:51 +02:00
Kristian Wind 1a610eaac4 Merge pull request #52 from kristianwind/notes-single-tap
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m25s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 15m11s
Notes: open on the first tap, not the second
2026-09-03 20:35:30 +02:00
kristianwindandClaude Opus 4.8 c2d4ce0179 fix(notes): open a note on the first tap, not the second
On a phone the list/editor switch keyed on the loaded note, which arrives a
moment after the tap — so the first tap looked like nothing happened and
people tapped again. It keys on the selected id now, set the instant a row
is tapped, so the pane switches at once; the back button is there
immediately and the body fills in when it arrives, with a quiet pane rather
than the "pick a note" prompt in the half-second between.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 20:27:28 +02:00
Kristian Wind a1b8cc6ddc Merge pull request #51 from kristianwind/notes-mobile-detail
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m51s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m24s
Notes on a phone: show the note (master-detail layout)
2026-09-03 19:15:05 +02:00
kristianwindandClaude Opus 4.8 8a2c46f5ea fix(notes): show the note on a phone — one pane at a time
On a phone the note list and the editor stacked in one column, so a tapped
note opened below a full-height list, out of sight — "kan vælge en note men
kan ikke se indholdet". Now it is master-detail: the list fills the screen
until a note is opened, the editor takes over once one is, and an iOS-style
"‹ Noter" at the top of the editor goes back. The cards drop their border
and corners on one column, where there is nothing beside them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 19:07:02 +02:00
Kristian Wind 8d42f34065 Merge pull request #50 from kristianwind/charcoal-apple-colors
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m47s
CI / Docker image builds (push) Successful in 1m48s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m30s
Match Charcoal theme to Apple Notes' exact colours
2026-09-03 17:51:16 +02:00
kristianwindandClaude Opus 4.8 905ceebc06 fix(theme): match Charcoal to Apple Notes' sampled colours
Kristian sent the exact values; the first Charcoal was a neutral grey guess
and missed them. Now: three panel greys, lightest sidebar (#2d3133) to
darkest editor (#1e1e1e) with the list (#242b2e) between — the reverse of
the usual sunken-is-darkest order, so the editor takes its own --editor-bg
rather than sharing the list's surface. The accent is amber: the selected
menu item is solid #ffce35 with dark text, a selected note is gold #a78c26
with dark text, and the section headings drop to a quiet grey (#8a8e90).

Each per-element colour is a fallback token only Charcoal sets, so every
other theme reads through to the shared token unchanged. Verified the
computed values land on Kristian's samples exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 17:42:55 +02:00
Kristian Wind 87c557941a Merge pull request #49 from kristianwind/theme-charcoal-polish
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m47s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m25s
Charcoal theme, elegant selection, rounded note list, confirm sign-out
2026-09-03 16:37:44 +02:00
Kristian Wind 0e1e803486 Merge pull request #48 from kristianwind/totp-qr
2FA: show a scannable QR code when enabling two-factor auth
2026-09-03 16:37:36 +02:00
kristianwindandClaude Opus 4.8 1dc9751a33 test(e2e): sign out with the two-step confirm
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:29:18 +02:00
kristianwindandClaude Opus 4.8 b81f8f67cc feat(ui): Charcoal theme, elegant selection, rounded note list, confirm sign-out
- A new Charcoal theme — neutral dark greys on the menu, note list and
  editor instead of the default's faint green cast, with matching inks;
  the green accent stays. It joins the theme picker on its own (the picker
  is data-driven from THEMES).
- The text selection is a translucent wash of the accent, not the old
  near-invisible tint and not a solid slab — visible on white and on the
  dark surfaces, keeping the text's own colour.
- The note list is a rounded card now, the same corner and border as the
  editor beside it, with a few pixels more air around the menu and the list.
- Signing out asks first: a second click within a few seconds confirms, so
  a button the pointer passes over does not drop you at the login screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:27:55 +02:00
kristianwindandClaude Opus 4.8 f62ffc8d92 feat(2fa): show a scannable QR code when enabling two-factor auth
The setup screen showed the secret and the otpauth URI as text and asked
you to scan — with nothing to scan. It renders the URI as a QR now, an SVG
(one dark path on a white card, so it reads in either theme) generated from
the barcode encoder already in the module graph via pquerna/otp. The secret
stays below it for a device that cannot scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:27:11 +02:00
Kristian Wind 73ee93deab Merge pull request #47 from kristianwind/offline-blip-resilience
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m0s
CI / Docker image builds (push) Successful in 1m56s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m34s
Offline: ride out short connection drops instead of losing the save
2026-09-03 15:35:03 +02:00
kristianwindandClaude Opus 4.8 b2fcc0b104 feat(offline): ride out short connection drops instead of losing the save
A save made while the connection blips for a few seconds is retried rather
than reverted: the request layer keeps trying a network failure for a
fifteen-second window, waking the instant the OS reports a link again, and
only falls back to the existing error path if the drop outlasts it. A quiet
"reconnecting" pill appears if the wait passes about a second, and leaves
the moment it heals — so a two-second blip is felt as nothing at all.

This is deliberately not offline mode: the app is a live view of a shared
list, so there is no cache to work in with no server and no write log to
reconcile. It is only the blip, ridden out. A server error (4xx/5xx) is the
server answering and is not retried; an aborted request (a superseded
search) is left to propagate.

Kristian: an offline function for when the net drops for short periods,
seconds. Shared items stay server-authoritative — nothing is edited against
a stale copy for longer than the blip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:26:46 +02:00
Kristian Wind 8dc601d09a Merge pull request #46 from kristianwind/app-icon-refresh
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m19s
CI / Docker image builds (push) Successful in 1m56s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m31s
Centre and strengthen the app icon
2026-09-03 15:01:15 +02:00
Kristian Wind 85e769a603 Merge pull request #45 from kristianwind/notes-three-line-rows
Notes: three-line rows with a filed line and dividers
2026-09-03 15:01:06 +02:00
kristianwindandClaude Opus 4.8 0363d5f422 feat(icon): centre and strengthen the app icon
The Wunjo rune sat left of centre and thin. It is heavier now (a 6.5
stroke), bigger, and nudged right of the geometric centre so the heavy
stave and the light flag balance about the middle of the tile — so it
reads clearly as a home-screen icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:53:21 +02:00
kristianwindandClaude Opus 4.8 05b7dac713 feat(notes): three-line rows like Apple Notes, with a filed line and dividers
Each note is three lines now: the title, then the date and the excerpt,
then where the note is filed — a folder like the menu's groups and the
project's name, the owner's when it is someone else's, or simply "Noter"
when it belongs to no project. The date leads the second line, left
aligned, instead of being pushed to the right of the title. A faint
hairline separates one note from the next, and a month heading is
capitalised ("August 2026", not "august 2026").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:52:56 +02:00
Kristian Wind cfd382e3df Merge pull request #44 from kristianwind/settings-link-style
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m3s
CI / Docker image builds (push) Successful in 2m8s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m58s
Style the unstyled "open completed tasks" link in settings
2026-09-03 14:35:20 +02:00
Kristian Wind bbcff40401 Merge pull request #43 from kristianwind/notes-header-new-design
Notes list: headers in the new heading design, rounded boxes
2026-09-03 14:35:10 +02:00
kristianwindandClaude Opus 4.8 01001d0980 fix(settings): the "open completed tasks" link was unstyled
It carried class="ghost", which nothing in the data page styles — so it
fell back to the browser's blue underline while every link beside it is
the accent-coloured .link. Now it matches them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:27:20 +02:00
kristianwindandClaude Opus 4.8 4ac7180cda feat(notes): drop the disclosure arrow from the note-list headers too
Same as the sidebar: the box folds on a click, the star and people icon
are the marks that mean something, and a date group keeps an empty spacer
so every label still starts at the same place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:25:55 +02:00
kristianwindandClaude Opus 4.8 e1683ef09d feat(notes): match the note-list headers to the new heading design
The note list still wore the old faint-uppercase group header beside the
sidebar's bold plain-case one, so the two columns read as two apps. The
group headers are bold, plainly cased and in the reading ink now, at
menu size, and their separator boxes carry the same rounded corner as an
active menu item — the hard rule underneath is gone, the box is the
separation. The disclosure mark stays: unlike a sidebar section, a
time-group has no icon, so it is the only thing that says "this folds".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:22:19 +02:00
Kristian Wind f89fb21502 Merge pull request #42 from kristianwind/sidebar-drop-chevrons
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 8m44s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m26s
Drop the fold arrows from sidebar headers and groups
2026-09-03 13:39:35 +02:00
kristianwindandClaude Opus 4.8 ec147cd1ba feat(sidebar): drop the fold arrows from headers and groups
The chevron beside every section and group was saying "clickable" a
second time next to an icon and a bold name that already said it. Gone
now: a system section folds from anywhere on its heading, and a group
folds from its folder icon while the name still opens the group's page.
No arrow on the rail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:34:33 +02:00
Kristian Wind 1f9f14099b Merge pull request #41 from kristianwind/calendar-lighter-pastels
CI / OpenAPI is valid (push) Successful in 53s
CI / End-to-end (push) Successful in 9m3s
CI / Docker image builds (push) Successful in 2m10s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m11s
Calendar: lighter grid, per-calendar pastels, today marked at top
2026-09-03 13:17:31 +02:00
kristianwindandClaude Opus 4.8 6cf63113df feat(calendar): lighter grid, per-calendar pastels, today marked at top
The calendar was a grey grid where every event was the same sunken block
edged in a colour you had to already know. Now the day itself is the
brightest surface and each event is a soft, dusty wash of its own
calendar's colour — so a screen of them reads as colour laid on white,
and two calendars are told apart at a glance. Tasks stay outlined cards,
the thing you can pick up, against the solid washes you cannot.

Today is marked where the day is named: its number sits in a filled disc
at the top of the column, the same one the month grid gives it, over a
faint wash down the whole column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:09:07 +02:00
Kristian Wind e24a0fa849 Merge pull request #40 from kristianwind/sidebar-header-unify
Unify section and group headers with icons; scrollbar flush to the divider
2026-09-03 13:02:15 +02:00
kristianwindandClaude Opus 4.8 1c223a2c26 fix(sidebar): scrollbar flush to the divider, and match the group test
The scrollbar thumb centred a 4px bar in a 10px track behind a
transparent border, so it floated three pixels shy of the right edge.
The thumb is the track now — a thin 6px rounded bar sitting flush
against the divider line, where a scrollbar belongs.

The group heading is a heading now, not a project row: the smoke test
measured a group against the projects filed under it and read the
section icon as a dot. It measures a group against "Projekter" — the
tier it shares — and reads the icon both headings carry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:54:05 +02:00
kristianwindandClaude Opus 4.8 a9c90ee6f1 feat(ui): unify section and group headers with icons
System sections (Projects, Filters, Labels) and user groups (Work,
Home) now share one design: a bold label at menu size, a leading
folder/funnel/tag/people icon, and an always-visible fold chevron.
Groups drop their colour dot for the same folder icon, so the sidebar
reads as one consistent hierarchy instead of two visual languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:34:07 +02:00
Kristian Wind dc4a584d5f Merge pull request #39 from kristianwind/sidebar-scrollbar-flush
fix(sidebar): scrollbar flush to the edge, thin and dim
2026-09-03 12:22:58 +02:00
kristianwindandClaude Opus 4.8 378980de0c fix(sidebar): the scrollbar sits at the edge, thin and dim
It sat a pad-width inside the right edge because the scroller lives inside the
sidebar's padding, so it read as floating in the middle. Pull the scroller out
to the border and pad the content back — the rows do not move — and make the
bar thin and dim so it is present without drawing the eye.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:14:39 +02:00
Kristian Wind 04c7a52a5a Merge pull request #38 from kristianwind/sidebar-color-polish
Release / Build and publish image (push) Failing after 5s
feat(ui): colour fixed-view icons, lift notes surfaces, pin the create header
2026-09-03 12:08:32 +02:00
kristianwindandClaude Opus 4.8 52df528994 test(e2e): retry the project→group drop until it nests
A native HTML5 drag occasionally does not register under CI load, leaving
the group empty and the group-page assertion failing for a reason unrelated
to the test. Retry the drop until the project actually sits under the group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:00:02 +02:00
kristianwindandClaude Opus 4.8 2f00246d6f feat(ui): colour the fixed-view icons, lift the notes surfaces, fix the create header
Following the Things pass, four bits of polish:

- The fixed views carry their own colour — amber star for Today, blue tray
  for the Inbox, red Upcoming, violet Waiting on others, teal Notes, indigo
  Calendar — and keep it whether or not they are the active row.
- The active row was all but invisible on the dark sidebar (a half-step of
  surface); it is now an accent-tinted selection that reads in both themes.
- The Projekter heading — the only place to add a project or a group — no
  longer scrolls away with a long list; it is pinned to the top of the
  scroller.
- The note list and the editor lift off the page: white in light, a nuance
  lighter than the ground in dark, so the two read as one writing surface.

Screenshots regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 11:47:17 +02:00
Kristian Wind acec77a20c Merge pull request #37 from kristianwind/sidebar-things-nav
Release / Build and publish image (push) Failing after 6s
feat(sidebar): icons for fixed views, bold areas, one straight edge
2026-09-03 10:17:56 +02:00
kristianwindandClaude Opus 4.8 59413a8b64 test(e2e): a grouped project now shares the left margin, not indented
The sidebar redesign puts every project at the same left edge; flip the
assertion from "indented more than a fixed view" to "in line with it".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:09:32 +02:00
kristianwindandClaude Opus 4.8 cf69cac410 feat(sidebar): icons for the fixed views, bold areas, one straight edge
Toward Things' calm: the fixed views carry icons instead of dots — a star
for Today, a calendar for Upcoming, a person for Waiting on others, a tray
for the Inbox. Areas (the user's groups) are set in bold, plainly cased, so
the eye lands on them the way it lands on an area in Things; the system
sections (Projekter, Filtre, Etiketter) stay quiet. And projects sit at the
same left margin whether or not they are in an area — one straight edge down
the sidebar rather than a staircase, with the bold heading doing the
grouping the indent used to.

Screenshots regenerated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:02:53 +02:00
Kristian Wind b0998fe524 Merge pull request #36 from kristianwind/settings-font-sizes
Release / Build and publish image (push) Failing after 11s
feat(settings): menu and body-text font size options
2026-09-02 15:39:06 +02:00
kristianwindandClaude Opus 4.8 8dfab618f7 feat(settings): menu and body-text font size, and a bolder menu by default
The sidebar read as muted 13px; the menu items now use their own size at
medium weight so they carry at a glance (Todoist-ish). Both sizes are
choices under Settings → Appearance:

- Menu font size drives a dedicated --menu-size the sidebar uses.
- Body text size re-scales the shared type tokens (the list and the notes),
  the same mechanism a look uses.

The two are independent on purpose — one is a glance down a list of names,
the other a paragraph you read. Kept in localStorage like theme and look,
and applied in app.html before first paint so a chosen size does not flash
in and resize. Four steps each (Small/Default/Large/Largest); Default
carries no attribute so the base tokens stand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:30:30 +02:00
Kristian Wind c0c1281415 Merge pull request #35 from kristianwind/capitalise-user-names
Release / Build and publish image (push) Failing after 5s
feat(users): always capitalise a display name's first letter
2026-08-30 09:32:33 +02:00
kristianwindandClaude Opus 4.8 247277e139 feat(users): always capitalise a display name's first letter
Andreas signed up as "andreas" and was shown that way everywhere. A name
is now capitalised on the way in — the first letter of each word raised,
the rest left as typed — in CreateUser (signup and invite-accept) and
UpdateProfile, so display, mail and export all agree.

Existing accounts are fixed by an idempotent startup pass
(NormalizeUserNames), the same shape as SealNotes: the rule is Unicode-aware
and per word, which SQLite's UPPER cannot spell, so it lives in Go rather
than a migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 09:25:50 +02:00
Kristian Wind f3b037c7f4 Merge pull request #34 from kristianwind/task-linkify-urls
Release / Build and publish image (push) Failing after 8s
feat(tasks): make a URL in a task clickable (title and description)
2026-08-27 16:40:25 +02:00
kristianwindandClaude Opus 4.8 4103e1b0ca fix(tasks): open the task by clicking its text, links carved out
The first cut put an open-button overlay over the row, which intercepted
every click on the title text — nine smoke tests (and real users) open a
task by clicking its text, and the overlay stole those clicks. Make the
body a role=button instead, with each link stopping its own click from
bubbling: plain text opens the task, a link opens the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 16:33:30 +02:00
kristianwindandClaude Opus 4.8 9d4a13c2c2 feat(tasks): make a URL in a task's title or description clickable
A task is plain text — the title is an input, the description a textarea —
so a pasted URL just sat there as words. It is now caught at display time
and shown as a link in the list and on the board, without the stored text
ever being anything but plain text.

The link cannot live inside the row's open-button (an <a> in a <button> is
invalid), so the body is a container with the open action as an overlay
behind it: plain text and the meta row still open the task, links are
raised above and take their own click. Conservative about what counts —
an explicit http(s):// or a bare www. host, not every dotted word.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 16:17:56 +02:00
Kristian Wind 923f911472 Merge pull request #33 from kristianwind/settings-buy-me-a-coffee
Release / Build and publish image (push) Failing after 6s
feat(settings): a quiet Buy me a coffee link
2026-08-27 14:55:59 +02:00
kristianwindandClaude Opus 4.8 272c4918b0 feat(settings): a quiet Buy me a coffee link
A donate link belongs in Settings and nowhere in the way — so it sits last,
understated, below the device list. A plain anchor with an inline SVG cup:
no third-party script, no webfont, no tracking, so it works offline and
cannot phone home. Wording says the app is MIT with nothing held back and
that nothing changes if you don't pay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 14:49:24 +02:00
Kristian Wind ff866cc514 Merge pull request #32 from kristianwind/drop-todoist-pro-claim
docs(site): drop the Todoist-Pro claim, lead with the Rune
2026-08-27 14:12:21 +02:00
kristianwindandClaude Opus 4.8 e94d731c30 docs(site): lead with the Rune, and drop the Todoist comparison from filters
Yggdrasil is self-hosting too, so labelling only the Docker page
"Self-hosting" was misleading and put it above the Rune it was built for.
Reorder "As a Rune" above the Docker page, rename that page "With Docker",
and make the landing hero's primary call to action the Rune, with Docker
beside it.

Also drop the "the grammar is Todoist's" line from the filters page — the
factual import/export interoperability keeps naming Todoist, but the
positioning and soft comparisons do not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 14:05:18 +02:00
kristianwindandClaude Opus 4.8 0aa0ec2989 docs: stop describing verdande as "Todoist Pro"
Claiming to be Todoist Pro is both inaccurate and an invitation to a
trademark complaint. Describe verdande on its own terms — a fast,
self-hosted task manager — and keep the Todoist references to what they
factually are: import/export interop, and a filter grammar close enough
that people coming from there already know it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 13:59:54 +02:00
Kristian Wind 21b60b007a Merge pull request #31 from kristianwind/site-refresh-delegate-snooze
docs(site): new features, refreshed screenshots and text
2026-08-27 13:08:34 +02:00
kristianwindandClaude Opus 4.8 7066e2e06e docs(site): new features, refreshed screenshots and text
The landing page and the docs now show and describe what has landed since
the last site update:

- the delegated-task marker (an arrow to whoever has it) — visible now in
  the board, list and Waiting-on-others shots
- snooze — one seeded task is parked so the board shows a card greyed at
  the foot of its column
- Web Push reminders to iPhone and Mac, dated tasks in a subscribed
  calendar, task durations on the calendar, and per-person note sharing

Screenshots regenerated against the current UI (PNG for docs/README, WebP
for the landing page, from the one capture). "Lately" brought current.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 13:00:05 +02:00
Kristian Wind 2b76a172e8 Merge pull request #30 from kristianwind/add-delegated-marker
Release / Build and publish image (push) Failing after 10s
feat(tasks): mark a delegated task with a hand-off arrow
2026-08-27 12:43:13 +02:00
kristianwindandClaude Opus 4.8 15495255ef test(e2e): the delegated chip's title is now "Uddelegeret til"
The assignee chip reads as a delegation now, so its title changed from
"Ansvarlig: {name}" to "Uddelegeret til {name}". Point the role-edit
smoke test at the new title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 12:36:48 +02:00
Kristian Wind 3b9b68f4b1 Merge pull request #29 from kristianwind/fix-mobile-priority-overflow
fix(mobile): stop the task drawer's priority/due row overflowing
2026-08-27 12:35:11 +02:00
kristianwindandClaude Opus 4.8 7dcd475036 feat(tasks): mark a delegated task with a hand-off arrow
A task assigned to somebody who is not you is out of your hands and into
theirs, but the row only showed their name — the same weight as any
other meta. The assignee is now a bordered chip led by an outgoing arrow
in the accent colour, so a list reads at a glance as which tasks are
yours to do and which you are waiting on. Covers the board too: its
cards render a TaskRow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 12:27:52 +02:00
kristianwindandClaude Opus 4.8 fc4877c3bd fix(mobile): stop the task drawer's priority/due row overflowing
On a phone the detail drawer's field grid stayed two columns, so each
field got half the viewport. Prioritet collapsed to "Ing" and the due
row — a date input plus fixed-width time and duration fields — ran off
the right edge. Below 560px the grid now stacks to one column per field
and the due row wraps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 12:24:29 +02:00
Kristian Wind 2da679d629 Merge pull request #28 from kristianwind/task-snooze
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 8m11s
CI / Docker image builds (push) Successful in 1m35s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m17s
Snooze: park a task at the bottom until a time
2026-08-25 11:07:39 +02:00
kristianwindandClaude Opus 5 9e3f20b050 feat(tasks): snooze — park a task at the bottom until a time
Snooze puts a task out of the way without finishing it or filing it: it sinks to
the bottom of its list and greys, and comes back on its own when its time passes.
No job wakes it — "snoozed" is just snoozed_until being still in the future, read
against the clock each time the list is ordered.

- `snoozed_until` on tasks (migration 0031); the list order sinks a still-snoozed
  task below everything, and it rejoins its place the moment the time passes.
- POST /tasks/{id}/snooze {until} — an RFC3339 time to snooze to, or empty to wake.
  Nothing else about the task moves; a snooze is about when you want to see it, not
  when it is due.
- The task drawer gains quick picks (this evening / tomorrow / next week) and a
  custom time, and a Wake now once it is snoozed. The row dims and carries a
  "snoozed until X" mark, and the list keeps snoozed tasks at the foot — the client
  sorts the same way the server does, so a drag-sorted list does not jump on reload.

Tests: snooze sinks a task below one made after it and waking returns it (Go); and
from the drawer the row greys, shows the mark, and un-greys on wake (e2e). Full
suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:59:34 +02:00
Kristian Wind fa816b2e41 Merge pull request #27 from kristianwind/ics-alldays-for-dated-tasks
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m52s
CI / Docker image builds (push) Successful in 1m19s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 13m49s
Fix: date-only tasks now appear in a subscribed calendar (ICS)
2026-08-25 08:48:47 +02:00
kristianwindandClaude Opus 5 60d66b728e fix(ics): a date-only task now shows in a subscribed calendar
The calendar feed emitted every task as a VTODO and, on top of that, an event only
for tasks that carried a clock time. But a subscribed calendar in Apple Calendar or
Google ignores VTODO entirely and draws only events — so a task with just a due
date, which is most of them, appeared in the verdande app's own calendar and
nowhere in the calendar it had been subscribed into. "Verdande does not update in my
calendar, only in the verdande app."

Every dated, unfinished task is an event now: a clock time makes a timed event as
before, a bare date an all-day one (a DATE start and an exclusive next-day end, so
it stays on its own day). Finished tasks stay off the calendar, as they were.

Test updated: the sample's timed task and its two date-only tasks all become events
now, the all-day ones in the DATE form, and the finished one still makes none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:42:25 +02:00
Kristian Wind bae3b3505f Merge pull request #26 from kristianwind/fix-apple-web-push-subject
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m12s
CI / Docker image builds (push) Successful in 1m18s
CI / Go (push) Failing after 14m7s
Fix: Apple rejected all push notifications (VAPID subject)
2026-08-25 08:30:45 +02:00
kristianwindandClaude Opus 5 7b1e7ba7c9 fix(push): Apple rejected the notifications; use the site's own URL as VAPID subject
Every Web Push to an iPhone or a Mac was refused, silently as far as the person
waiting for the reminder could tell. The cause was the VAPID "sub" claim: on an
instance with no mail configured it fell back to mailto:verdande@example.invalid,
and Apple answers 403 Forbidden to a mailto whose domain is made up. Verified
against web.push.apple.com on the live subscriptions — that mailto gets a 403, the
instance's https URL a 201.

So the subject is now a real mailto when SMTP.From is a real address, and otherwise
the instance's own https base URL — which is a valid VAPID subject, points at
something that exists, and every push service including Apple accepts.

Two things found alongside it, both fixed:

- A successful push did not clear the failure count. A subscription drops at ten
  failures, and all four live ones sat at nine from this bug — one flaky push from
  being deleted despite working. MarkPushDelivered zeroes the count and moves
  last_used_at on every delivery, so a subscription that recovers is not still
  carrying its old failures toward the cliff.

- A reminder with no project made a notification whose URL was "/projekt/" — a
  dead link. Empty project now points at the app root.

Test: the subject is a real mailto when there is one, and the https URL when there
is not — including for the localhost default, which is not a real address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:24:20 +02:00
Kristian Wind c0dbea10c5 Merge pull request #25 from kristianwind/note-footer-archive
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m8s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
Add an Archive button to the open note's footer
2026-08-24 15:45:14 +02:00
kristianwindandClaude Opus 5 a70ca93102 feat(notes): an Archive button in the open note's footer
Archiving a single note meant finding its row in the list and its hover-only
button. With a note open to read or write, the one place you would reach for it —
its own footer, beside Gem and Slet — had no way to put it away.

There is one now: Arkivér between Save and Delete, which reads "bring it back" once
the note is already archived. Owner only, like delete, and it runs the same archive
the row button does — the note leaves the list without leaving the account.

e2e: archiving from the footer takes the note off the list, the archive holds it
with a bring-back button, and bringing it back returns it. The existing bulk-archive
test is scoped to the selection bar, now that "Arkivér" also lives in the footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:37:17 +02:00
Kristian Wind 2bda3a26db Merge pull request #24 from kristianwind/inline-note-links
CI / Go (push) Failing after 13m26s
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 6m45s
CI / Docker image builds (push) Successful in 1m38s
Release / Build and publish image (push) Failing after 7s
Make [[note]] an inline clickable link, not bracketed text
2026-08-24 13:48:59 +02:00
kristianwindandClaude Opus 5 8022502015 feat(notes): [[note]] is an inline link, not bracketed text
Choosing a note from the [[ completion wrote [[Title]] as plain text. It made a
real link behind the scenes — it showed in the panel under the note — but in the
note itself it stayed brackets, and "it doesn't become a link" is exactly how that
reads. A wiki link is a link where it is written.

Now a [[note]] renders inline as the title, in the accent colour, and a click on it
opens the note it points at. It is atomic — contenteditable="false" — so it moves
and deletes as one thing and a click is a click, not a caret landing mid-word. And
it appears the moment it is chosen: the completion drops the finished link in,
brackets and all swallowed, rather than text waiting to become a link when the note
is next opened.

The seam holds both ways: markdownToHtml lifts [[title]] into the link the same way
it already lifts an image, and htmlToMarkdown reads the link back to [[title]] by
the title it carries — so what is on disk is still the same Markdown, and a note
opened, saved and reopened is unchanged.

e2e: choosing a note writes an inline link, it survives the round-trip through
Markdown and a reload, and clicking it opens the note it names. Full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:41:04 +02:00
Kristian Wind b07938e351 Merge pull request #23 from kristianwind/clickable-note-links
CI / Go (push) Failing after 14m28s
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m18s
CI / Docker image builds (push) Successful in 1m29s
Release / Build and publish image (push) Failing after 6s
Fix: a [[note]] link is one you can actually follow
2026-08-24 13:19:40 +02:00
kristianwindandClaude Opus 5 de95df44c1 fix(notes): a [[note]] link is one you can follow
Picking a note from the [[ completion wrote [[Title]] and the server made a real
link from it — but the link only ever showed in the panel under the note as plain
text. It said the note pointed somewhere and gave no way to go there, which reads
as "it didn't become a link at all".

The links in that panel are buttons now. A [[note]] resolves by its title to the
note — the one already on the list, or looked up if not — and opens it in place; a
#project goes to its page; a task link to the task. Titles with punctuation work,
brackets and parentheses included, because the match is on the whole title, not a
parse of it.

e2e: after linking a note, clicking its chip opens the note it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:11:46 +02:00
Kristian Wind 2d7b6a1d5e Merge pull request #22 from kristianwind/fix-notes-export-order
CI / OpenAPI is valid (push) Successful in 10s
CI / Go (push) Failing after 13m11s
CI / Docker image builds (push) Successful in 1m40s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 7m10s
Fix: export notes in a stable order (flaky export test)
2026-08-24 12:44:36 +02:00
kristianwindandClaude Opus 5 4bbc49fe7e fix(notes): export the notes in a stable order
AllNotes returns newest-first by the second, so two notes written in the same
second have no order between them — and the notes export walks that list assigning
filenames, giving the first note of a name the plain filename and the next the
"(2)". With the order undecided, which of two same-titled notes got which name was
a coin toss that landed differently on each export, and the export test failed
roughly one run in three.

Sort the export by when each note was made, with the id to break a same-second
tie. The first note of a name keeps the name; the archive is the same twice
running. Ids are time-ordered, so the tie-break agrees with creation order rather
than fighting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:39:11 +02:00
Kristian Wind db2bf619ce Merge pull request #21 from kristianwind/note-link-autocomplete
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m42s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m24s
Fix: [[ completes a note link, like # completes a project
2026-08-24 12:30:04 +02:00
kristianwindandClaude Opus 5 6116ed2e21 fix(notes): [[ completes a note link, the way # completes a project
Typing [[ did nothing. The toolbar hint offered "[[ another note" and the parser
turned a fully-typed [[Title]] into a link on save, but the completion that makes
it usable — the one # has for projects — was never there, so you had to know and
type a note's title exactly, brackets and all.

Now [[ opens the same picker # does, over the notes the page has loaded: the
current note is left out (a note linking to itself is a loop nobody meant), and
one without a title yet cannot be pointed at. Choosing a note closes its own
brackets — the opener is already in the text — and leaves a space, so the next
word is a word and not more of the tag. On save the link is real and shows in the
note's links, not merely as text.

The picker's two kinds are told apart where a project wears its colour: a note
suggestion wears the brackets that open it.

e2e: [[ offers a note by title, choosing it writes [[Title]] into the note, and
the link lands in the note's links. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:22:38 +02:00
Kristian Wind 1ba26ee723 Merge pull request #20 from kristianwind/site-features-light-shots
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m50s
CI / Docker image builds (push) Successful in 1m44s
CI / Go (push) Failing after 14m5s
Release / Build and publish image (push) Failing after 6s
Website: new features and light-theme screenshots
2026-08-24 10:37:07 +02:00
kristianwindandClaude Opus 5 fcedab0b19 docs(site): add the new features and light screenshots of them
The landing page did not mention two things the app grew — sharing a note with a
person, and task durations — and its screenshots were all in the dark theme.

- Copy: a cell for sharing a note or a page (to a person, or by filing it in a
  project, with the Shared with me group), and one for how long a task takes (drawn
  to length on the calendar, dragged by its foot, kept when moved).

- Two new screenshots in the light theme, at the top of the gallery: the calendar
  drawing timed tasks to their real length, and a note somebody shared gathered
  under Shared with me. English throughout, like the rest of the site — the capture
  account is English, so the interface is.

The generator grew the demo data these need: a handful of timed tasks with lengths
across the week, and a note the colleague shares back to the owner. Every other
screenshot was recaptured from the current build in the same pass, so the whole set
reflects what ships today (the tidied note footer, and the rest).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:30:00 +02:00
Kristian Wind 72b6ea3803 Merge pull request #19 from kristianwind/task-durations-calendar
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m58s
CI / Docker image builds (push) Successful in 1m40s
CI / Go (push) Failing after 13m55s
Task durations: draw, resize, and edit a task's length
2026-08-24 10:22:37 +02:00
kristianwindandClaude Opus 5 4f25905d81 feat(calendar): a task's length is drawn, resizable, and editable
A timed task was drawn at a fixed half hour whatever its `duration_min` said, so a
length the data model already carried was invisible and uneditable. Three parts:

- The calendar draws a task at its real length — a quarter-hour floor so a
  zero-minute task is still grabbable, the day's end as the ceiling — instead of a
  flat thirty minutes. A ninety-minute task now looks like ninety minutes.

- A grip along the foot of a timed task drags its length. Pointer events, not the
  HTML5 drag the body uses, and stopPropagation between them — otherwise making a
  task longer would pick it up and move it instead. It snaps to the quarter hour,
  follows the handle live, and writes once on release through a new store `resize`.
  Moving a task (the existing day/time drag) leaves the length alone.

- The task drawer gains a length field beside the time — minutes, shown only with
  a time to hang it on, since a duration without a start is a length of nothing and
  the calendar has nowhere to draw it. So a length can be set off the calendar too.

e2e: dragging the foot of a task makes it longer both on screen and in what the
server stores, and the time does not move. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:14:39 +02:00
Kristian Wind e3eeb3037c Merge pull request #18 from kristianwind/tidy-note-share-footer
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m51s
CI / Docker image builds (push) Successful in 1m37s
CI / Go (push) Failing after 14m7s
Tidy the note sharing UI: gather it behind a Del button
2026-08-24 10:05:08 +02:00
kristianwindandClaude Opus 5 97e6be3afa fix(notes): gather sharing behind a Del button so the footer stays tidy
The sharing controls were laid out across the note's footer — the project select,
the whole "Del med personer" panel and the Save/Delete buttons all on one row. It
crammed: "Læg i projekt" wrapped to two lines and the people panel ran into the
rest.

Now one Del button in the footer opens a popover that holds both ways a note is
shared — a project and named people — stacked in a small card. The footer is a
status on the left and Del / Gem / Slet on the right, and nothing wraps. The
button carries a count when the note is shared with someone, so "shared" is
legible without opening it.

The popover closes on a click outside, on Escape, and on switching to another
note — the last tracked by note id, so a save returning a fresh copy of the same
note does not slam it shut mid-use. The inner "add someone" button is now Tilføj,
so it does not read as a second Del.

e2e updated to open the popover before reaching the controls; full Chromium suite
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:58:02 +02:00
Kristian Wind d67b61d3c6 Merge pull request #17 from kristianwind/fix-mono-block-escape
CI / Go (push) Failing after 13m12s
CI / OpenAPI is valid (push) Successful in 9s
CI / Docker image builds (push) Successful in 1m52s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 6m13s
Fix: let a monospace line be left — on return and through the menu
2026-08-24 09:34:38 +02:00
kristianwindandClaude Opus 5 f0d18bb800 fix(notes): let a monospace line be left — on return and through the menu
A <pre> is preformatted, so the browser answered Enter by inserting a <br> and
keeping the caret inside it. A monospace last line therefore trapped everything
typed after it in monospace, with no way back to body by typing — and the style
menu could not get out either: `formatBlock('p')` on a line inside a <pre> nests a
<p> *inside* the block (`<pre>…<p>body</p></pre>`), so the text stayed monospace
and the note was left malformed. Reported as "monospace keeps going and I can
neither delete it nor switch to body".

Two fixes, both in the editor:

- Plain Enter in a <pre> now steps out: the tail after the caret becomes a
  paragraph below the block, and that is where the caret lands. Shift+Enter is
  untouched, so a soft line break still writes multi-line code inside the block —
  Enter to leave, Shift+Enter to stay.

- The style menu converts anything touching a <pre> by hand, replacing the element
  rather than reformatting inside it, so a monospace line goes cleanly back to body
  (or to a heading) instead of nesting.

The split can leave an empty text node in the new paragraph, and a block with no
element and no text is not a caret host in Chromium — the next keystroke falls back
into the block above. So an empty paragraph is given a <br> to stand in, which was
the difference between the caret landing in the new line and the text reappearing
in the code.

e2e: a monospace line left on Enter is body after it, Shift+Enter stays monospace,
the menu returns it to body, and the whole thing survives the round-trip through
Markdown and a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:27:44 +02:00
Kristian Wind 7f8d925299 Merge pull request #15 from kristianwind/share-notes-between-users
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 6m39s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m33s
Release / Build and publish image (push) Failing after 5s
Share notes with people, not only through a project
2026-08-24 08:33:06 +02:00
kristianwind cdab817141 Merge main: pick up the calendar e2e flake fix 2026-08-24 08:25:51 +02:00
Kristian Wind 2e76ced5fa Merge pull request #16 from kristianwind/fix-flaky-calendar-drag
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m45s
CI / Docker image builds (push) Successful in 1m47s
CI / Go (push) Failing after 14m5s
Fix flaky calendar e2e: count the test's own events, not the whole day
2026-08-24 08:25:41 +02:00
kristianwindandClaude Opus 5 711fb4838b test(e2e): the calendar test counted other tests' tasks as its own
The calendar test asserted `[data-date=day] .tevent` had a count of exactly one —
the mocked meeting — after dragging its task to the next day. That count assumed
the day held nothing but this test's own events. It does not: the suite shares one
database, and an earlier test drags a task across a month boundary, which on some
dates lands a timed task on this very day. A timed task renders as a `.tevent`
too, so the count was two, and the test failed — but only in the full run, and
only when the date arithmetic put the two tests on the same cell. Alone it passed,
which is exactly the shape of a shared-state collision rather than a real bug.

Named, not counted: assert the meeting is still there and the task is no longer in
the day's column, which is what "the events stayed, the task moved" actually
means — and is true however full another test left the day. Same for the all-day
band. No product code changed; the drag and everything it checks are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:19:17 +02:00
kristianwindandClaude Opus 5 643b30555e test(e2e): follow the renamed share label, and cover person-to-person sharing
The project-filing control was relabelled "Del i" → "Læg i projekt" and its empty
option "Kun mig" → "Intet projekt" (a note can now be shared with people while
under no project, so "kun mig" was no longer true). The two e2e tests that drove
that control by its old label are updated to the new one.

And a new test for the feature itself: an owner shares a note with a second
account from the Del med personer panel, the recipient's own list grows a Delt
med mig group with the note in it, a viewer's row carries no star or archive
control, and unsharing takes it back off their list. The recipient is navigated
on the origin the invite signed her in on — VERDANDE_BASE_URL is localhost while
the suite drives 127.0.0.1, and a session cookie set on one host is not sent to
the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 07:59:57 +02:00
kristianwindandClaude Opus 5 91671a4f97 feat(notes): share a note with a person, not only through a project
Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.

A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.

The store:
  - note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
    recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
    notes shared directly with the reader.
  - the effective role folds a direct share on top of the owner and project paths.

The rules, drawn where they protect the owner:
  - read = viewer, edit text = editor, on any path.
  - MOVE a note (change its project) is owner-only: an editor filing it into a
    project the owner is not in would take the owner's own note away from them.
  - DELETE is owner or project-editor — a direct share, however high its role,
    does not reach to throwing the note away.
  - SHARE and UNSHARE are the owner's alone, like a project's invitations.
  - the picker is everyone on the instance (a closed, invited set) minus who is
    already on the note; a made-up id and the owner themselves are refused.

The list:
  - a "Delt med mig" group at the top, collapsible, that appears only when it has
    something in it — the same rule Favoritter follows — so it costs nothing when
    you share nothing. A shared note lives there, not also in a time bucket.
  - every shared note carries a small chip in its owner's avatar colour, the
    project mark's shape said for a person.
  - no star or archive button on a note somebody else owns: those change the note
    itself, and it is not yours to put away.
  - the editor grows a "Del med personer" panel for the owner: who can see it, at
    what role, and an add row drawn from the instance's other people.

The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.

Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.

Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 07:36:55 +02:00
Kristian Wind 15be5d2165 Merge pull request #14 from kristianwind/security-hardening-headers
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m30s
CI / Docker image builds (push) Successful in 1m27s
CI / Go (push) Failing after 14m29s
Security hardening: HSTS, forwarded-IP trust, single-use TOTP
2026-08-23 23:41:44 +02:00
kristianwindandClaude Opus 5 77994eff09 feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code
stays valid for its whole thirty-second window, so one seen in flight — a
phishing page, a proxy, over the shoulder — could be replayed until it expired.

Tasks now record the counter of the last accepted step (totp_last_step), and a
login accepts a code only when its step is strictly greater. The guard is in the
UPDATE, not a read-then-write, so two logins racing with the same stolen code
cannot both find the old value and both proceed — exactly one moves the row.

auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the
same one-step window; the code entered a little early or late spends the step it
was issued at, not the step it was checked at, so a late-but-valid code is still
single-use. Verified end to end: the same code presented to two logins in one
window is accepted once and refused the second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:34:34 +02:00
kristianwindandClaude Opus 5 b4038356f0 feat(security): trust a forwarded IP only from a proxy, and set COOP
The rate limiter and the audit log key on the caller's address, and that address
was read with chi's middleware.RealIP — which believes X-Forwarded-For from
anyone. So a caller could set it to a new value on every request and get a fresh
password-guess bucket each time, and could write any address they liked into the
log the panel's watchers read. The one control meant for when a password is
already gone was keyed on a number the attacker got to pick.

Now the header is believed only when the machine that *connected* is a trusted
proxy — by default anything on a private network, which is the shape of a reverse
proxy or tunnel sharing the host or the docker bridge. A connection straight off
the internet is trusted for nothing and is keyed on the address it actually came
from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no
proxy at all); VERDANDE_REAL_IP_HEADER names the header.

Verified in the current deployment before the change: behind the Cloudflare
Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared
overwrites it. This makes that safety a property of the code, not of the
deployment — expose the instance directly, or put a header-passing proxy in
front, and it would have been a live hole.

Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating
the browsing context. verdande scripts no other window, so there is nothing to
lose and a class of cross-window attacks to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:34:34 +02:00
kristianwindandClaude Opus 5 8a87cfeb44 feat(security): HSTS og en lukket Permissions-Policy på hvert svar
To headere, appen ikke satte, og som begge følger af, hvad den allerede er.

Sessionen bor i en __Host-cookie, der kun findes over HTTPS — hele modellen
forudsætter TLS. Uden HSTS holder den forudsætning ikke mod en netværksangriber:
den allerførste navigation kan strippes til http://, og cookien rækkes over i
klartekst, før Secure-flaget nogensinde kommer i spil. HSTS lukker vinduet, og
den er sat på nøjagtig samme betingelse som cookiens Secure-flag (secureCookies),
så en dev-instans på ren HTTP aldrig bliver bedt om et certifikat, den ikke har.
To år, underdomæner med; ingen preload — at blive hard-kodet ind i browsere er en
beslutning for domænets ejer, ikke for en selv-hostet app.

verdande beder aldrig om kamera, mikrofon eller placering, så den sikreste
Permissions-Policy er at slå dem alle fra: et indsat element kan ikke bede om det,
siden selv har afvist. Navngivet felt for felt frem for med et jokertegn, fordi
jokertegns-formen ikke forstås ens af alle browsere, og en regel, en browser
ignorerer, er ingen regel.

Prøve på begge: HSTS følger HTTPS og udebliver over HTTP, og Permissions-Policy
lukker for kameraet uanset skema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:15:33 +02:00
Kristian Wind 3e52eea2da feat(kvasir): én mail bliver til én opgave, uanset hvem der læser den
CI / OpenAPI is valid (push) Successful in 15s
CI / End-to-end (push) Successful in 9m46s
CI / Docker image builds (push) Successful in 1m46s
CI / Go (push) Failing after 15m23s
Release / Build and publish image (push) Failing after 5s
Fundamentet under Kvasir, og det er værd at have uden ham.

Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere
af samme postkasse — en anden assistent, en gentaget kørsel, et menneske —
laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal
faktura fra Browns" to gange i samme kørsel.

Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`,
`imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke
hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det
betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en
nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og
det her skal virke uden.

Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der
læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal
være ude af stand til" er en betingelse frem for en aftale, nogen husker.
Afgrænset til den, mailen kom til — to personer på samme instans, der begge
modtager tråden, får hver sin opgave, og det er rigtigt.

CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge
mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som
læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da
af standser samme sted.

`TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer
triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave,
der blev lavet af en mail og smidt væk, er også et svar.
2026-08-22 09:19:41 +02:00
Kristian Wind acd6784333 test(e2e): kalendertesten afhang af, hvilken ugedag den blev kørt på
CI / OpenAPI is valid (push) Successful in 11s
CI / Go (push) Failing after 13m58s
CI / Docker image builds (push) Successful in 2m43s
CI / End-to-end (push) Successful in 9m12s
Grøn i månedsvis, rød i morges — på en commit, der ikke var i nærheden af en
kalender. Det kostede en runde at tro, det var min egen ændring.

Testen lægger opgaven på i morgen og trækker den til dagen efter. Er i morgen en
søndag, er kilden sidste kolonne i sin række og målet første celle i den næste —
nede i den del af gitteret, et 720 px vindue skærer af. Et syntetisk træk slipper
i målets midtpunkt uden den auto-scroll, en rigtig markør får af browseren, så et
mål under folden bliver aldrig ramt. I går lå begge celler i samme række.

To ting rettet, og den anden er den vigtigste:

- Vinduet er højt nok til alle seks rækker under månedsdelen.
- Målet tages fra gitteret frem for at blive regnet ud af datoen. Dagen efter i
  morgen ligger ikke altid i den måned, der vises — i enden af en kort måned
  falder den udenfor — mens cellen ved siden af altid gør. Med et fald tilbage
  til cellen før, for den ene dag om måneden hvor der ikke er en efter.

Appen fejler ikke noget: en rigtig bruger ruller.
2026-08-22 08:48:31 +02:00
Kristian Wind 3e8d609316 docs: imaget hentes fra GHCR, og prøven for 'offentlig' var forkert
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 8m44s
CI / Docker image builds (push) Successful in 1m28s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m22s
Adressen rettet fem steder tilbage til ghcr.io. Og rune-siden lærte folk at
teste synlighed med `docker --config /tmp/empty pull`, hvilket er den fejl,
der tog instansen ned: en tom config stopper ikke maskinens credential-helper,
så pullet autentificeres uden at se sådan ud og melder succes for et image,
ingen fremmed kan hente. Prøven er nu registrets token-endepunkt med curl.
2026-08-22 00:29:57 +02:00
Kristian Wind fcd2443007 fix(rune): tilbage til GHCR — Gitea kan ikke servere imaget uden login
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 8m20s
CI / Docker image builds (push) Successful in 1m31s
CI / Go (push) Failing after 14m24s
v5 flyttede imaget til gitea.nolimit.dk og tog instansen ned. Panelets
PullImage sætter aldrig RegistryAuth, så registret skal kunne læses uden
legitimation, og det kan Gitea her ikke: token-endepunktet svarer 401 for
kw/verdande, også efter at repoet blev gjort offentligt. Instansen kræver login
på alt, registret inklusive.

Jeg påstod det modsatte, og beviste det med en test, der ikke kunne bevise
noget: `docker --config <tom-mappe> pull`. En tom config stopper ikke maskinens
credential-helper, så pullet blev autentificeret uden at se sådan ud. Den
rigtige prøve er registrets token-endepunkt med curl — GHCR svarer 200 med et
token, Gitea svarer 401. Begge dele målt.

Fejlen er værd at kunne genkende: panelet river containeren ned for at anvende
runen, pullet fejler, pullImageRetry kasserer fejlen, og ContainerCreate slår
derefter op lokalt og siger `No such image`. Serveren står som "running" uden
container_id, udadtil en 502, og intet sted nævnes det manglende login.

Imaget ligger nu begge steder — samme digest, sha256:f7b23efd… — så koden bliver
i Gitea, mens containeren hentes fra GHCR, indtil Gitea kan servere anonymt.
2026-08-22 00:28:06 +02:00
Kristian Wind 27a7b04ff3 test: kassens rigtige tailnet-adresse ud af testen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 9m34s
CI / Docker image builds (push) Successful in 1m33s
CI / Go (push) Failing after 14m37s
Jeg skrev instansens faktiske Tailscale-adresse ind som prøvedata. Den er
ubrugelig uden adgang til tailnettet, men den er stadig et kort over én persons
opsætning — og af præcis den grund er arbejdspapiret om flytningen gitignoreret.
Så skal en test ikke bære adressen i stedet. En neutral adresse fra samme
område prøver det samme.
2026-08-22 00:05:27 +02:00
Kristian Wind b6cbc6cbc2 fix(ai): din egen model kunne ikke sættes op — tre fejl i én oplevelse
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 9m31s
CI / Docker image builds (push) Successful in 1m56s
CI / Go (push) Failing after 14m40s
Rapporteret som "AI gemmer ikke mine oplysninger". Anden gang det symptom
meldes, og en helt anden årsag end sidst — dengang delte scopes i user_settings
én række og slettede hinanden.

Udbyderen hedder "OpenAI-kompatibel (Ollama, LM Studio, …)" og feltet foreslår
selv http://localhost:11434/v1. safedial nægter præcis den slags adresser. Så
den ene udbyder, der findes for at køre sin egen model, kunne ikke sættes op,
og formularen foreslog en adresse, serveren ville afvise.

Serveren svarede 422 med en fejl på base_url. Formularen havde ingen plads til
at vise en fejl på det felt, og fordi fejlobjektet ikke var tomt, blev der
heller ikke vist en toast. Beskeden forsvandt, og det så ud, som om der ikke
skete noget ved at trykke Gem.

Tre rettelser:

- Private adresser tillades for admins. Muren står, fordi en server, der henter
  en brugerangivet adresse, lader den, der angiver den, nå ting han ellers ikke
  kan. En admin på en selvhostet instans ejer allerede værten og dens netværk,
  så det giver ham intet nyt. For en inviteret bruger er den lukket som før.
  Tilladelsen regnes ud pr. forespørgsel, ikke gemt ved siden af adressen — den,
  der ikke længere er admin, skal ikke kunne nå indenfor på et gammelt flag.

- Tailnet er ikke "indenfor". Halvdelen virkede allerede ved et tilfælde: Gos
  IsPrivate dækker RFC1918 og ikke det CGNAT-område, Tailscale låner, så 100.x
  slap igennem uden at nogen havde besluttet det. IPv6-halvdelen gjorde ikke —
  fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså
  tilgængelig eller ej alt efter adressefamilie. Begge områder er nu navngivet,
  hvilket gør IPv4-halvdelen bevidst frem for heldig.

- Feltfejl kan ikke længere forsvinde. base_url og model har fået et sted at
  vise en, og en fejl på et felt, der ikke har et, råbes op som en toast frem
  for at blive tabt.
2026-08-22 00:03:45 +02:00
Kristian Wind 223a1df85e ci: checkout prøver begge auth-former og siger hvilken der virkede
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Failing after 9m52s
CI / Docker image builds (push) Successful in 1m32s
CI / Go (push) Failing after 14m53s
Serverens side er bekræftet — Gitea tillader fetch ved hash, målt direkte mod
instansen. Tilbage stod kun, om act_runners auto-token duer som kodeord i
basic auth, og hvilket brugernavn den vil have. Det kan kun en kørende runner
svare på.

Feedback-sløjfen går gennem en kø og et andet menneske, så en kørsel, der kun
siger 'checkout fejlede', er for dyr. Trinnet prøver nu x-access-token først —
den form actions/checkout bruger, og som økosystemet beviser virker mod Gitea —
derefter $GITHUB_ACTOR, og skriver hvilken der blev godkendt. Én kørsel
besvarer spørgsmålet, uanset om den ender grøn eller rød.
2026-08-21 23:51:06 +02:00
Kristian Wind c85bdd5076 ci: CI kan udløses i hånden
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Failing after 9m43s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 14m53s
Køen på Gitea kan kun ryddes ved at slå Actions fra og til igen, og det
dropper alt, også den kørsel man ville beholde. Uden en dispatch-udløser er
den eneste vej tilbage at skubbe en tom commit — en løgn om, at der skete
noget. Nu kan en kørsel startes mod uændret kode.
2026-08-21 23:32:45 +02:00
Kristian Wind 28825dc7cb chore: arbejdspapiret om Gitea-flytningen bliver på maskinen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 9m51s
CI / Docker image builds (push) Successful in 1m40s
CI / Go (push) Failing after 15m4s
Samme grund som HANDOFF.md: GITEA-FLYTNING.md navngiver kassen, runneren,
registret og hvordan man kommer til dem. Det er et kort over én persons
opsætning og hører ikke til i et repo, der kan blive offentligt.
2026-08-21 23:17:19 +02:00
Kristian Wind c930e6163e ci: Gitea bygger selv — og uden at hente handlinger fra GitHub
CI / OpenAPI is valid (push) Successful in 51s
CI / Go (push) Failing after 15m36s
CI / Docker image builds (push) Successful in 2m55s
CI / End-to-end (push) Failing after 9m26s
DEFAULT_ACTIONS_URL står ikke i app.ini på gitea.nolimit.dk, og standarden er
github.com. Et `uses: actions/checkout@v4` i et Gitea-workflow bliver derfor
hentet fra GitHub, hver gang, også når både koden, runneren og registret er
vores egne — så et workflow, der bruger markedspladsen, ser uafhængigt ud uden
at være det.

Begge Gitea-workflows er skrevet om uden `uses:`. Værktøjskæden kommer fra
jobbets container, hvilket er det, setup-go ellers ville bruge et trin på;
checkout, login, metadata og buildx er de rå kommandoer, handlingerne pakker.

Det, der ikke fulgte med: rapporten fra en fejlet e2e-kørsel. upload-artifact
har ingen `run:`-form, så grunden står i loggen frem for i en zip.

Udgivelsen fik samtidig en rettelse, GitHub-udgaven ikke har: en forudgivelse
(v1.4.2-rc1) får kun sit eget mærkat. At lade en rc overskrive :latest eller :1
ville sende den ud til alle, der troede, de fulgte det stabile spor.

Issue-skabeloner i .gitea/ for sig: Gitea kræver `about`, hvor GitHub skriver
`description`, og afviser en skabelon uden.
2026-08-21 23:14:12 +02:00
Kristian WindandClaude Opus 5 20b8c12b4a build: imaget hentes fra vores egen Gitea, og runen peger derind
GitHub er utilgængeligt i ti dage. Koden ligger allerede begge steder; det, der
manglede, var imaget og et sted at bygge det.

**Imaget ligger i Gitea nu**, begge arkitekturer. Kopieret med
`docker buildx imagetools create`, som flytter hele manifest-indekset mellem to
registre uden at hente lagene ned — en `docker pull` på denne maskine ville have
hentet arm64 og efterladt serveren uden en amd64 at køre.

**Og det kan hentes uden legitimation, mens repoet bliver ved med at være
privat.** Det var ikke givet: panelets `PullImage` sætter aldrig `RegistryAuth`,
så et privat register er udelukket, og Gitea gør normalt en container-pakke privat
sammen med det repo, den hører til. Men en pakke bindes kun til et repo, når den
skubbes med et repo-link — og denne er ikke, så dens synlighed følger brugeren
`kw`, som er offentlig.

Bevist frem for antaget: `docker --config /tmp/tom pull` hentede den med samme
digest som GHCR havde. En bar `curl` mod `/v2/…/manifests/…` svarer 401 også for
et læsbart image, fordi registret uddeler et anonymt token først — så den er ikke
prøven, og den kostede en runde at gennemskue.

**Runen er bumpet til version 5.** Ikke fordi felterne ændrede sig, men fordi en
rune ikke opdaterer sig selv: panelet skriver rune-rækken fra en UI-handling, så
ændringen kræver **Runes → verdande → Update**, og katalog-listen caches ti
minutter.

**Et Actions-workflow til Gitea** ligger i `.gitea/workflows/`. Sin egen fil frem
for en delt: `secrets.GITHUB_TOKEN` findes ikke der, registret er et andet, og der
er ingen Releases-side at skrive til. En delt fil ville være fire betingede led,
der hver kun gælder ét af de to steder. Den laver ingen release, fordi
opdateringsnotitsen i appen læser GitHubs — skal den flyttes med, er det
`internal/update`, der skal have en ny adresse.

**Det virker ikke endnu:** der er ingen runner registreret på Gitea. Indtil der er,
bygges og skubbes imaget i hånden herfra.

Dokumentationen og landingssiden siger den nye adresse. Rune-siden er skrevet om,
hvor den fortalte om GHCR-pakkeindstillinger, og siger nu, hvordan man prøver
efter på den måde, der faktisk svarer på spørgsmålet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:53:18 +02:00
Kristian WindandClaude Opus 5 8d5dd1f1c3 fix(notes): grupperne stod tomme — hver liste rullede for sig
Release / Build and publish image (push) Failing after 9m59s
Meldt ind som "noter vises ikke i den nye listeform": hver eneste overskrift stod
der med sit tal, og der var ikke en eneste note under nogen af dem.

Før var der ét `<ul>`, og *det* rullede. Da listen blev delt i grupper, blev hver
gruppe sit eget `<ul>` — og dermed sit eget flex-element med `overflow-y: auto`.
Flex-elementer skrumper som udgangspunkt, og et element, der skrumper og klipper,
viser ingenting. Med tyve måneder i søjlen fik hver liste nul at være høj i.

Rulningen hører til kassen om dem alle, ikke til hver af dem.

**Prøven fangede det ikke, og det er værd at forstå hvorfor.** Den lavede to noter,
altså tre grupper, i et vindue på 720 pixels — og med så få grupper er der plads
nok til, at skrumpningen ikke bider. Fejlen kræver pres. Prøven gør vinduet lavt
nu og kigger efter, at rækkerne kan *ses* og ikke bare findes, og den siger
desuden hvor rulningen ligger: på kassen, ikke på listerne. Den er set fejle med
den gamle stil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:00:15 +02:00
Kristian WindandClaude Opus 5 c8d67dd07d feat(notes): notekroppe forsegles, og søgningen åbner dem selv
CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver
eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det
ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til.

**Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af
en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.

**Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden:
`notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en
translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været
teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk,
og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads.

**Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB
koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den
spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne;
fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver
krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den
forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og
delordssøgningen falder bort.

bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest:
titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet
fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren
optælling laver, og den er let at lave og svær at få øje på.

**En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger
forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede
importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og
det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et
gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en
konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste
den rækkefølge væk, importen brugte en dag på at hente hjem.

En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er
ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans,
der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der
siger fra.

Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden:
opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke
projekter og opgaver en note peger på, men ikke hvad der står.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:40:55 +02:00
Kristian WindandClaude Opus 5 23bd784b07 docs: SECURITY.md sagde noget, dagen havde gjort forkert
"Ingen ratebegrænsning ud over login og nulstilling" var sandt i morges. Det er
det ikke længere: andenfaktoren, beaconet og indgående post har fået deres eget
loft i løbet af dagen — det sidste tilføjede jeg selv og skrev ikke det her.

En SECURITY.md, der siger noget forkert, er værre end de fleste fejl. Den er
netop den fil, nogen læser for at afgøre, om de tør køre det her, og hvert eneste
punkt i den er en påstand, de ikke selv kan efterprøve uden at læse koden.

Samme sted mangler den største åbne ting: notekroppe, opgavetitler, beskrivelser
og kommentarer er ikke krypteret. Forseglingen dækker legitimation — postkassers
kodeord, OAuth-tokens, andenfaktorens hemmelighed, AI-nøgler — og intet andet. En
note med et kodeord i har det i klartekst i hver sikkerhedskopi, man kan hente.
Det stod i overleveringen og på dokumentationssiden for noter, og det hørte
hjemme her.

README's "ikke færdigt"-liste havde Gmail med. Den kører i produktion, inklusive
flagningen og dubletbehandlingen, der tog en dag at få rigtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:16:52 +02:00
Kristian WindandClaude Opus 5 264f48ae11 fix(quickadd): "hver anden tirsdag" blev til en dato og to ord i titlen
Release / Build and publish image (push) Failing after 10m3s
Vendingen er den, folk bruger, og recurrence-pakken har kunnet den siden den blev
skrevet — den oversætter "hver anden" til "hver 2." netop for at slippe for at
skrive hvert eneste mønster i to udgaver.

Sætningen nåede bare aldrig frem til den. Mønsteret i hurtig tilføjelse tog et tal
efter "hver", men ikke et ord, så det, der blev givet videre, var "hver anden".
Det er ikke en regel. Så blev de to ord stående i titlen, "tirsdag" blev læst som
en forfaldsdato, og opgaven gentog sig ikke — uden at noget sagde det.

Sløjfen, der prøver sig frem, kunne ikke redde den: den fjerner ord fra enden, én
ad gangen, og kan ikke hente et ord, mønsteret aldrig tog med.

Fundet af en prøve, jeg skrev til noget andet, hvor jeg brugte vendingen som en
selvfølge og opdagede, at den ikke var det.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:11:01 +02:00
Kristian WindandClaude Opus 5 2219f6af13 fix(i18n): en gentagelse siges nu på læserens sprog
Release / Build and publish image (push) Failing after 10m2s
`recurrence.Describe` skriver dansk. Fladen kunne navngive de fem almindelige
regler selv, og alt andet — "hver mandag", "hverdage", "hver anden uge tirsdag" —
faldt tilbage til serverens tekst, som er dansk uanset hvad kontoen er sat til.

Serveren kan ikke bare tage en locale med, og det er ikke dovenskab: `toTaskJSON`
har niogtyve kaldesteder, og mange af dem er WebSocket-udsendelser, hvor én
nyttelast når alle i et projekt på én gang. Der findes ikke ét sprog at skrive den
i.

Men reglen er den samme, uanset hvem der læser den. Den ligger allerede i
nyttelasten, så beskrivelsen hører hjemme dér, hvor læseren er — i fladen.

Serverens tekst bliver stående som reserve. Den er stadig den, et API-kald og en
MCP-forbindelse får, og de har ingen flade at oversætte i.

Ugedagen kommer fra `Intl` og ikke fra en tabel — ikke for at spare syv strenge pr.
sprog, men for at få de navne, sproget faktisk bruger. Dagene sorteres, som ugen
går, frem for som reglen tilfældigvis skrev dem: "mandag og torsdag" og "torsdag og
mandag" er den samme regel, og den ene af dem er den, folk siger.

Og to har sit eget ord på begge sprog — "hver anden", "every other". Det er ikke
tallet to sat ind i den samme sætning, så det er sin egen nøgle. Et sprog, der
bøjer enheden efter tallet, kan ikke sættes sammen af stumper; derfor står tallet
inde i teksten frem for at blive limet på.

Prøven bruger `hver mandag` og `hverdage`, fordi de netop *ikke* står på listen
over de fem, fladen altid har kunnet. En tredje vending, jeg først skrev prøven
med — "hver anden tirsdag" — viste sig ikke at være en, hurtig tilføjelse
overhovedet læser: den lagde "hver anden" i titlen og gjorde "tirsdag" til en dato.
Det er en anden sag, og den står nu skrevet ét sted, hvor den kan tages op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:00:05 +02:00
Kristian WindandClaude Opus 5 f64b7c1f01 docs: Noter og Kalender havde ingen sider, mens forsiden lovede begge
Landingssiden reklamerer for noter og for en kalender, og dokumentationen havde
hverken en side om den ene eller den anden. Det hul blev skrevet ned i morges og
er det her.

**Noter** siger, hvad der er værd at vide, og hvad der ikke virker: at første linje
*er* titlen og udledes ved hver gemning, at arkivet og papirkurven er to
forskellige ting, at søgningen finder `grøn` når man skriver `gron` og
`keepInventory` når man skriver `inventory` — og at notekroppe ligger i klartekst i
hver sikkerhedskopi, man kan hente. Det sidste hører hjemme dér, hvor nogen står
med en note og overvejer at skrive et kodeord i den.

**Kalender** samler det, der før var spredt: gitteret, ugen som et døgn, træk der
sætter et klokkeslæt, abonnementer på ICS og webcal, og Google-forbindelsen med
det forbehold, den skal have — den er skrevet efter dokumentationen og har aldrig
kørt mod Google. `caldav.md` er nu den anden retning alene: verdandes egne opgaver
læst af noget andet.

En ting, byggeriet lærte: `</>` i inline-kode vælter Pythons html-parser på 3.9
med "we should not get here!". CI kører 3.12 og er ligeglad, men en side, der ikke
kan bygges lokalt, er en side ingen retter. Knappen er beskrevet med ord i stedet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:50:27 +02:00
Kristian WindandClaude Opus 5 868588de10 feat(projects): et projekts kalender kan vises som en uge
Ugen fandtes på Kommende og på Kalender, men ikke dér hvor et projekt bor — og det
er dér, "hvornår sker de her fire ting" oftest bliver spurgt.

Inde i kalendervisningen frem for som en fjerde `view_mode`. `view_mode` er
projektets og delt med alle, der kan se det, mens uge eller måned er et spørgsmål
om hvor bred skærmen er, foran hvilken der sidder én person — samme grund som
sidebjælkens bredde ligger i localStorage af. Og en fjerde værdi ville kræve en
tabelombygning: `CHECK (view_mode IN ('list','board','calendar'))` kan SQLite ikke
ændre på plads.

**Og så fandt den en fejl, der har ligget og ventet.** `cursor` blev sat med
`span === 'week' ? … : …`, hvilket læser `span` i det øjeblik komponenten bliver
til og fanger den værdi for altid. Byggeriet har advaret om det lige så længe. Så
længe hver visning havde sin egen komponent, gjorde det ingen forskel — og det
gjorde det i samme sekund, en vælger begyndte at skifte `span` på den samme.
Markøren stod på den 1. i måneden, og et skift til uge landede på ugen omkring den
1.: "27. jul.–2. aug." for en person, der kiggede på august.

Rettet, med den ene undtagelse skrevet ned: en månedsvisnings markør *er* den
første i måneden, så et skift til uge, mens man står i den indeværende måned,
skal lande på den uge, i dag ligger i — ikke på ugen omkring den 1.

Og en anden fælde, jeg selv gik i undervejs: en underklasses felter sættes *efter*
basens konstruktør er kørt. Nøglen til localStorage som et felt på en underklasse
betød, at valget blev gemt ét sted og læst et andet — en uge, man havde valgt, var
en måned igen efter en genindlæsning. Nøglen er et argument nu; et argument har
ingen rækkefølge at tage fejl af.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:45:38 +02:00
Kristian WindandClaude Opus 5 dcb71f4c14 feat(calendar): abonnement på flere kalendere, ICS og webcal
Release / Build and publish image (push) Failing after 10m3s
Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den
eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google
afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op.

**Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er
præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet,
farven, `shown` og hele fejemaskinen omkring sig. En tabel til ville være en anden
vej til det samme sted, og to veje til ét sted er to steder at rette den dag noget
om begivenheder ændrer sig.

Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og
udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har
flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden
adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene
ting, SQLite kan her, som ellers krævede en tabelombygning.

**`webcal://` skrives om til `https://`.** Det er ikke en protokol, noget taler —
det er en konvention, der får et skrivebord til at give adressen til
kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres links
sådan. At afvise den ville være at afvise folk på en teknikalitet.

**Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så
den, der står med feltet, får svaret med det samme — og igen på den *opslåede*
adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen,
kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den
sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et
SSRF-fund i morges.

**Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl
langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen
står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes,
er den samme tavshed, notifikationerne havde.

Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje
ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke
tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til
den dag de skal.

En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den,
som filen skriver den, dækker hver eneste helligdag også dagen efter sig.

Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons
server, og den ordinære tilstand for en anden persons server er, at den er i orden
indtil en morgen hvor den ikke er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:26:13 +02:00
Kristian WindandClaude Opus 5 968a29ca67 feat(notes): listen står i grupper, og projektet har sin egen farve
Release / Build and publish image (push) Failing after 9m56s
Meldt ind som "den er for tæt på en eller anden måde". Grunden var ikke afstanden
mellem rækkerne: hver række sagde tre ting, og to af dem gentog sig selv nedad.
Datoen stod på hver eneste række — også når tyve noter deler dag — og projektet
fik en linje for sig selv. Tre linjer pr. note, gange tolv hundrede.

Gentagelserne er flyttet derhen, hvor de siger noget.

**Datoen er blevet til overskriften.** I dag · I går · Denne uge · August 2026 …
og så er den væk fra rækken. Noten går fra tre linjer til to, og luften kommer af,
at der er mindre at skrive — ikke af at listen bliver længere.

**Favoritterne har fået deres egen gruppe.** De lå allerede først; der stod bare
ikke hvorfor.

**Grupperingen følger sorteringen.** Sorteret på "Oprettet" grupperes der på
oprettelsesdatoen, på "Senest rørt" på den — ellers ville listen stå i én
rækkefølge og være grupperet efter en anden, og så er overskrifterne løgn.
Sorteret på navn er der ingen dato at gruppere på, så det bliver forbogstavet, og
datoen kommer tilbage i rækken, hvor den så er det eneste tilbage, der siger noget.

**Arkivet grupperes med.** Det er dér, de tolv hundrede importerede ligger, og
dér, det betyder mest.

**Projektet har sin egen farve** — den samme prik som i sidebjælken. Mærkaten sagde
det samme som alle de andre i en grå, der var lysere end uddraget, og projektet er
dét, man skanner efter, når man leder i tolv hundrede noter. Prikken bærer farven
og ikke teksten: fem projektfarver som skriftfarve er fem forskellige grader af
læsbarhed, og den lyseste er ikke læsbar.

Fire ting, der blev fundet ved at se på det:

- Uddraget gentog titlen, fordi titlen *er* første linje. Det var skjult, så længe
  en dato stod imellem dem.
- Uddraget klippes ikke af sig selv. Det lå før i en flex-boks, der ejede
  afkortningen; da datoen flyttede op, forsvandt boksen, og sætningen løb videre
  ind under favoritstjernen.
- Overskriftens stjerne hed `.star` ligesom rækkens favoritknap og arvede dens
  gennemsigtighed. Den stod der aldrig. To ting, der ligner hinanden, er ikke det
  samme.
- En `aria-label` på foldeknappen erstatter knappens indhold som dens navn — og
  knappen *er* overskriften, så overskriften kom til at hedde "Fold I dag sammen".
  Sidebjælkens gruppehoveder gør det rigtigt, og gør det nu begge steder.

Sammenfoldning huskes ikke på tværs af besøg: en gruppe, der åbner foldet uden at
nogen bad om det, er en liste, der ser tom ud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:01:40 +02:00
Kristian WindandClaude Opus 5 fcd6dca852 fix(push): "der kommer ikke nogen" kunne ikke besvares
Release / Build and publish image (push) Failing after 10m0s
Alt om det var usynligt. Indstillingssiden sagde "til for denne enhed", så snart
der fandtes et abonnement — også når hver eneste push blev afvist. Og en afvist
push gik i en logline på **Debug**, som ikke skrives på en instans, der kører på
standardniveau. Så en levering, der fejler lydløst, og en levering, der aldrig
sker, så ens ud, og forskellen er hele diagnosen.

Tre forskellige problemer havde ét udseende: browseren tilmeldte sig aldrig,
tjenesten afviste den, eller der var ikke noget forfaldent endnu.

**En prøveknap** under Indstillinger → Notifikationer sender én notifikation
gennem den rigtige rute — ikke en genvej, så det, den beviser, er det, der
faktisk skal virke — og siger hvad hver enhed gjorde ved den. Kom den ikke frem,
og afviste ingen den, står der, at det så er browseren eller styresystemet, der
holder den tilbage. Nu peger svaret på ét af de tre steder.

Loglinjen er hævet fra Debug til Warn. Det var den ene linje, der ville have sagt
hvorfor.

Teksten kommer fra klienten, fordi ordbøgerne gør. En server, der bærer sin egen
kopi af "det her er en prøve", bærer den på ét sprog.

**Og en lækage, prøven fandt.** Go pakker en transportfejl som `*url.Error`, og
dens tekst er `Post "https://fcm.googleapis.com/fcm/send/dQw4…": dial tcp: …` —
hele endepunktet, som er den legitimation, push-tjenesten udstedte til den enhed.
Den var på vej ud i svaret, i loglinjen og i ethvert skærmbillede af nogen af dem.
Prøven var skrevet for at bevise, at kun værten blev nævnt, og det var sådan den
blev fundet. Fejlen pakkes ud, og adressen skrubbes bagefter alligevel: den næste
fejltype, der bærer den med, kommer ikke med en advarsel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:41:33 +02:00
208 changed files with 19814 additions and 1040 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Bug
# Gitea kalder feltet `about`, hvor GitHub kalder det `description`, og Gitea
# *kræver* det: en skabelon uden bliver afvist frem for at blive vist. Derfor
# ligger skabelonerne her for sig frem for at være delt med `.github/`.
about: Something does not work
labels: [bug]
body:
- type: textarea
id: what
attributes:
label: What happened
description: And what you expected instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to reproduce it
placeholder: |
1. …
2. …
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: The image tag, or the commit you built from.
validations:
required: true
- type: dropdown
id: how
attributes:
label: How are you running it
options:
- As a Rune in Yggdrasil Panel
- Docker directly
- From source
validations:
required: true
- type: textarea
id: logs
attributes:
label: Anything in the logs
description: Please remove tokens and email addresses first.
render: text
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security issue
url: https://gitea.nolimit.dk/kw/verdande/src/branch/main/SECURITY.md
about: Please report vulnerabilities by email, not as a public issue.
+24
View File
@@ -0,0 +1,24 @@
name: Feature
about: Suggest something
labels: [enhancement]
body:
- type: markdown
attributes:
# Absolut adresse, ikke GitHubs `../../blob/main/…`: Gitea lægger en fil
# under `/src/branch/<gren>/`, så den relative form peger ingen steder her.
value: |
Please check the [non-goals](https://gitea.nolimit.dk/kw/verdande/src/branch/main/README.md#non-goals)
first — some things are deliberately not being built.
- type: textarea
id: problem
attributes:
label: What are you trying to do
description: The problem, not the solution. What does the current version make hard?
validations:
required: true
- type: textarea
id: idea
attributes:
label: What you had in mind
validations:
required: false
+273
View File
@@ -0,0 +1,273 @@
name: CI
# Gitea-tvillingen til .github/workflows/ci.yml. Den prøver at nå samme resultat
# ad en anden vej, og forskellen er hele pointen med filen:
#
# INGEN `uses:`. Gitea Actions kan læse GitHubs format, men `DEFAULT_ACTIONS_URL`
# står ikke i app.ini på gitea.nolimit.dk, og standarden er github.com — så et
# `uses: actions/checkout@v4` her ville blive hentet FRA GitHub, hver gang, også
# selvom både koden, runneren og registret er vores egne. Et workflow, der bruger
# markedspladsen, er derfor ikke uafhængigt af GitHub; det ser bare sådan ud.
#
# Prisen er, at værktøjskæden skal komme et sted fra. Den kommer fra jobbets
# container, hvilket er billigere end det lyder: `container: golang:1.26` er
# nøjagtig det, setup-go ellers ville bruge et trin på at installere.
#
# Alternativerne, hvis nogen senere vil skrive filen om: sæt DEFAULT_ACTIONS_URL
# til gitea.com, som spejler de gængse handlinger — men det flytter kun
# afhængigheden fra én andens server til en andens, og det er en indstilling for
# HELE instansen, altså også for alt andet, der bygger der. Denne fil rører kun
# dette repo.
on:
push:
branches: [main]
pull_request:
# Så en kørsel kan udløses uden at skulle opfinde en commit. Det er ikke
# bekvemmelighed: uden den er den eneste måde at få CI til at køre igen mod
# uændret kode at skubbe noget tomt, og en tom commit i historikken er en
# løgn om, at der skete noget.
workflow_dispatch:
# Ingen `concurrency:`-blok. GitHub-udgaven aflyser en kørsel, når en nyere
# kommer, og det er stadig det rigtige at gøre — men understøttelsen kom sent til
# Gitea Actions, og et felt, runneren ikke kender, er en fejl ved oversættelsen
# frem for en ignoreret linje. Den kan sættes på, når instansen er en version,
# der kan.
jobs:
go:
name: Go
runs-on: ubuntu-latest
# Containeren ER værktøjskæden. `-race` kræver en C-oversætter, og den er med
# i det officielle golang-image — et slankere ville koste et apt-trin.
container:
image: golang:1.26
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
# Håndrullet, fordi actions/checkout ville blive hentet fra GitHub.
# Det er de samme fire greb, den handling tager: init, remote, en
# fetch af præcis den commit, der udløste kørslen, og et checkout.
#
# Legitimationen sættes med -c på selve fetch-kommandoen frem for i
# remotens adresse. En adresse med token i havner i .git/config, i
# `git remote -v` og i enhver fejlbesked, der citerer den; en header
# gør ikke.
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
- name: Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt'd:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
# -race is the whole point of running tests in CI for a server with a
# WebSocket fan-out: a data race is exactly the bug that passes locally.
- name: Test
run: go test -race -count=1 ./...
- name: Build
run: CGO_ENABLED=0 go build ./...
spec:
name: OpenAPI is valid
runs-on: ubuntu-latest
container:
image: node:22
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
# Structural validity only. That the spec matches the *router* is checked by
# a Go test, which is the half that actually goes stale.
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
e2e:
name: End-to-end
runs-on: ubuntu-latest
# node frem for golang her: Playwrights browsere trækker et lag
# Debian-pakker, som npm-imaget er vant til at have, mens Go til gengæld er
# fire linjer at hælde ind. Den anden vej rundt er den dyre.
container:
image: node:22
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
- name: Go
run: |
set -eu
# Versionen læses ud af go.mod frem for at stå her. To steder at rette
# er ét for mange, og den slags glemmes lige indtil CI bygger med en
# anden Go end udviklerne.
version=$(awk '/^go /{print $2; exit}' go.mod)
arch=$(dpkg --print-architecture)
curl -fsSL "https://go.dev/dl/go${version}.linux-${arch}.tar.gz" | tar -xz -C /usr/local
echo /usr/local/go/bin >> "$GITHUB_PATH"
- name: Install
working-directory: web
run: npm ci --no-audit --no-fund
# 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 webkit
# The suite builds the frontend, embeds it and starts the real binary — see
# playwright.config.js. There is nothing to build here first.
- name: Test
working-directory: web
run: npm run test:e2e
# Ingen rapport at hente ned bagefter. GitHub-udgaven lægger
# web/playwright-report/ op som et artefakt, og det trin ER en handling fra
# markedspladsen — der er ikke en `run:`-form af det. Går suiten i stykker
# her, står grunden i loggen frem for i en zip. Skal rapporten tilbage, er
# det upload-artifact spejlet ind lokalt, ikke en linje mere i denne fil.
docker:
name: Docker image builds
runs-on: ubuntu-latest
# Ingen `container:` her: jobbet skal bruge docker-klienten og buildx, og dem
# har runnerens eget ubuntu-image. En golang- eller node-container ville have
# sproget og mangle værktøjet.
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
# Build only — publishing happens on a tag, in release.yml. This job exists
# so a Dockerfile that no longer builds fails on the pull request rather
# than at release time.
#
# `type=cacheonly` frem for `--load`: resultatet skal ingen steder hen, og
# at hente et image ind i daemonen for straks at glemme det er et halvt
# minut hver kørsel. Én arkitektur er nok til at fange en Dockerfile, der
# ikke bygger; de to rigtige bygges på et mærkat.
- name: Build
run: |
set -eu
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
docker buildx build --platform linux/amd64 --output type=cacheonly .
+126
View File
@@ -0,0 +1,126 @@
name: Release
# Samme udløser som GitHub-workflowet: et semver-tag, og ikke andet.
#
# Gitea Actions læser samme format som GitHub Actions, men det er *ikke* det
# samme sted. Filen ligger derfor for sig frem for at være delt: der er intet
# `secrets.GITHUB_TOKEN`, registret er et andet, og der er ingen Releases-side at
# skrive til. En delt fil ville være fire betingede led, der hver kun gælder ét
# af de to steder.
#
# Og som i ci.yml: ingen `uses:`. Handlinger hentes fra github.com, så længe
# DEFAULT_ACTIONS_URL ikke er sat — se den lange forklaring der.
on:
push:
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
jobs:
image:
name: Build and publish image
# `ubuntu-latest` er runnerens egen etiket (`ovh-1`, docker-mode). Ingen
# `container:`: jobbet skal bruge docker-klienten og buildx, og dem har
# runnerens image.
runs-on: ubuntu-latest
env:
REGISTRY: gitea.nolimit.dk
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
# setup-qemu-action i rå form. Uden den bygger buildx kun værtens egen
# arkitektur, og arm64 er ikke valgfrit: et hjemmelab er lige så tit en
# Raspberry Pi eller en Apple silicon-VM som en x86-kasse.
- name: Emulation for the other architecture
run: docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64
- name: Builder
run: docker buildx create --use --name release-verdande 2>/dev/null || docker buildx use release-verdande
# `GITEA_TOKEN` er automatisk til stede i et Actions-job og har lov at
# skrive pakker for den, der ejer repoet. Ingen hemmelighed at sætte op.
# --password-stdin frem for --password: det sidste ville stå i processens
# kommandolinje, som enhver på maskinen kan læse.
- name: Log in to the registry
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: printf '%s' "$TOKEN" | docker login "$REGISTRY" -u "$GITHUB_ACTOR" --password-stdin
# metadata-action i rå form.
#
# v1.4.2 udgiver 1.4.2, 1.4, 1 og latest — så en rune, der er bundet til
# :1, får rettelser uden nogensinde at krydse et hovedversionsskifte.
#
# En forudgivelse (v1.4.2-rc1) får KUN sit eget nøjagtige mærkat. At lade
# en rc overskrive :latest eller :1 ville sende den ud til alle, der troede
# de fulgte det stabile spor — og mærkatmønsteret ovenfor lukker dem ind.
- name: Work out the tags
id: tags
run: |
set -eu
version=${GITHUB_REF_NAME#v}
image="$REGISTRY/$GITHUB_REPOSITORY"
args="--tag $image:$version"
case "$version" in
*-*) echo "Forudgivelse: kun $version." ;;
*)
minor=${version%.*}
major=${version%%.*}
args="$args --tag $image:$minor --tag $image:$major --tag $image:latest"
;;
esac
echo "args=$args" >> "$GITHUB_OUTPUT"
echo "Mærkater: $args"
- name: Build and push
run: |
set -eu
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
${{ steps.tags.outputs.args }} \
--build-arg "VERSION=$GITHUB_REF_NAME" \
--label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
--label "org.opencontainers.image.version=$GITHUB_REF_NAME" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
.
# Ingen `cache-from: type=gha`. act_runner har en cache-tjeneste, der
# svarer på det navn, men den er ikke prøvet her, og en kold bygning,
# der tager nogle minutter længere, er billigere end en udgivelse, der
# fejler på noget, som ikke har med bygningen at gøre.
# Ingen Release oprettes.
#
# GitHub-workflowet laver en Release, fordi opdateringsnotitsen i appen læser
# `releases/latest` derfra. Gitea har sit eget Releases-begreb, men notitsen
# peger på GitHub — så en release her ville ingen læse. Skal notitsen flyttes
# med, er det `internal/update`, der skal have en ny adresse, og det er en
# ændring for sig. (Bemærk også, at GitHub-udgavens softprops-handling taler
# mod api.github.com og ikke kan genbruges her.)
+10
View File
@@ -3,6 +3,11 @@ name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
#
# And on pull requests, where the build is the only check there is: `mkdocs build
# --strict` fails on a link to a page that does not exist, and building only after
# the merge means finding that out on `main`. Nothing is published from a pull
# request — the `deploy` job below runs on a manual dispatch and nothing else.
on:
push:
branches: [main]
@@ -10,6 +15,11 @@ on:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
pull_request:
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
+57
View File
@@ -17,6 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Taggen selv, ikke kun den commit den peger på: udgivelsesteksten
# nedenfor står i tagbeskeden, og en overfladisk checkout henter
# commit'en uden den.
fetch-depth: 0
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -60,7 +65,59 @@ jobs:
#
# Created after the image, so a release never announces a version that
# failed to build.
# Tagbeskeden ER annonceringen. Den skrives, når der tagges, på produktets
# eget sprog og til dem, der bruger det — og indtil nu nåede den aldrig
# frem: udgivelsen på GitHub blev fyldt med en autogenereret liste over
# commit-overskrifter, som er skrevet til dem, der læser koden.
#
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
# side, der linker til sig selv, hjælper ingen.
#
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
# actions/checkout henter først alle tags korrekt — og overskriver derefter
# netop den ene, der udløste kørslen:
#
# git rev-parse refs/tags/v0.46.0
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
#
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
# commitbeskeden, og det var flettecommittens overskrift, der stod som
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
#
# Derfor hentes tagget som *ref* igen her, før det læses.
- id: notes
run: |
git fetch --force origin \
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
# frem for at blive opdaget på siden bagefter.
case "$body" in
"${GITHUB_REF_NAME}"*) ;;
*)
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
exit 1
;;
esac
{
echo 'body<<VERDANDE_EOF'
printf '%s\n' "$body"
echo VERDANDE_EOF
} >> "$GITHUB_OUTPUT"
- uses: softprops/action-gh-release@v2
with:
body: ${{ steps.notes.outputs.body }}
# Stadig sat: den autogenererede liste lægges *under* teksten ovenfor,
# så både den, der vil vide hvad der er nyt, og den, der vil se hvilke
# ændringer det var, kan læse det samme sted.
generate_release_notes: true
make_latest: true
+4
View File
@@ -67,5 +67,9 @@ node_modules.nosync/
HANDOFF.md
NOTES-PLAN.md
# Samme grund: instruktionen til flytningen navngiver kassen, runneren, registret
# og hvordan man kommer til dem. Arbejdspapir, ikke produktdokumentation.
GITEA-FLYTNING.md
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
.claude/worktrees/
+35 -13
View File
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
## Why this exists
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
integrations that a hosted product will not build for you: a real CalDAV server, a
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
lists — on your own hardware, with your own data, and with the integrations that a
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
and an MCP endpoint so Claude can work your task list directly.
## What it does
@@ -36,17 +37,32 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
lists, quotes, fenced code. A note is shared by filing it in a project;
`#project` and `[[another note]]` are links, and a task shows the notes that
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
lists, quotes, fenced code. A note is shared by filing it in a project, by
handing it to one person — including somebody who has no account yet, who gets an
invitation that becomes the share when they sign up — or by writing `@Sofie` in
the text, which shares it with her and tells her. `#project` and
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **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.
everybody arrives through an invite link — to a project, to a single note, 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.
- **Notifications that are worth reading.** A bell for what other people did to what
you share — folded, so one person typing for five minutes is one line — pushed to
your devices and counted on the app's icon in the Dock.
- **AI that suggests and never writes.** Optional, off by default, your own key and
your own model if you want. It tidies the inbox, pulls the tasks out of a meeting
note, writes a morning plan, turns *"SV: SV: Vedr. levering uge 12"* into the
action the mail asks for, and answers questions about your own notes with links to
what the answer rests on. Every one of them arrives as a line you accept, edit or
refuse — a model that edits your tasks directly has to be right every time to be
worth having.
- **Anything can put a task in the inbox.** A mail to your own forwarding address,
or a POST of one line of text from a shortcut on your phone, a script or any
service that can call a URL.
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
@@ -102,17 +118,23 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| CalDAV server (two-way VTODO) | Done, tested |
| Mail-to-task | Done, tested |
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
| Times on tasks, set by dragging in the week grid | Done, tested |
| Notes: archive, grouped list, images, source view | Done, tested |
| Update notice for administrators | Done, tested |
| Personal API tokens | Done, tested |
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
API calls are implemented against their specifications and have only ever been
exercised there, not against live services. Everything else has an interface: opening a task shows
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
implemented against their specifications and have only ever been exercised there,
not against live services. Gmail is no longer in that list — it runs in production,
including the flagging and de-duplication that took a day to get right. Calendar
subscriptions over ICS and webcal are proven; the Google half of the calendar is
not. Everything else has an interface: opening a task shows
its description, sub-tasks, comments, files and reminders, and the settings pages
cover the account, notifications, integrations, AI, API tokens, import, export and
templates.
+23 -3
View File
@@ -39,8 +39,28 @@ reply within a week.
- **The CSP** is computed at startup by hashing the inline scripts in the built
page, rather than allowing `unsafe-inline`.
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
only the body: a note's title *is* its first line, so a readable title beside a
sealed body would leave the beginning of every note in the clear.
The two plaintext copies went with it. `notes_fts` indexed the body and the
generated `fold` column held a transliterated copy of it — sealing the body while
leaving those would have been theatre, since a stolen file could be read straight
out of the index. Search opens the text in Go instead, which costs 33–43 ms
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
- **Task titles, descriptions and comments are still in the clear**, and so is
`note_links` — ids derived from a note's body, so a stolen file still shows which
projects and tasks a note points at, though not what it says.
## What it does not do
verdande has no rate limiting beyond the login and password-reset endpoints, and
no audit log of reads. It is built to be run behind something — a Cloudflare
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
password, the second factor, the installation beacon, and inbound mail. **Nothing
else is limited** — an authenticated caller can ask as often as they like, and a
signed-in account that wanted to be a nuisance can be one.
There is no audit log of reads. Who *changed* something is recorded per project;
who looked at it is not.
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
VPN — by somebody who knows what they exposed.
+28
View File
@@ -64,6 +64,32 @@ func run() error {
}
db.UseSecrets(box)
// Noter, der stadig ligger åbne, lukkes nu.
//
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
// så den koster et gennemløb af id'er hver anden gang.
//
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
// er værre end en, der siger fra.
if sealed, err := db.SealNotes(context.Background()); err != nil {
return fmt.Errorf("seal notes: %w", err)
} else if sealed > 0 {
log.Info("notes sealed", "count", sealed)
}
// Names created before the app capitalised them on the way in — "andreas"
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
// caught up. A failure here does not stop the start: a lower-case first name is
// a blemish, not a broken instance.
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
log.Error("normalize user names", "err", err)
} else if fixed > 0 {
log.Info("user names normalized", "count", fixed)
}
httpapi.Version = version
api := httpapi.New(cfg, db, log, webAssets(log))
@@ -78,6 +104,8 @@ func run() error {
runner.SyncCalendars = api.SyncCalendars
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.NotifyOfUpdates = api.NotifyOfUpdates
runner.Start(jobCtx)
srv := &http.Server{
+78 -9
View File
@@ -43,23 +43,92 @@ The key is stored on your account and is **never sent back** to the settings pag
a page that repopulates a password field is one that will eventually leak it into a
screenshot.
## The shape of all of it
**The model proposes; you decide.** Nothing is written until you say yes, and the
proposal is a line in the same syntax you type yourself — `Ring til Anders i morgen
p1 #Firma` — sitting in a field you can edit before you accept it.
That is not caution for its own sake. A model that edits your tasks directly has to
be right *every* time to be worth having. One that suggests only has to be right
often enough to save you some typing, and when it is wrong you have already seen it.
It is also why the suggestion is a line rather than a set of fields: there is one
reading of "i morgen" in this program, and a suggestion goes through the same
[quick add](quick-add.md) parser as anything you type — including after you have
edited it by hand.
The model is given the names of the projects that exist and may name no others. A
`#Projekt` it invented would create one by accident the first time you accepted it,
and a guess must not be able to move a task somewhere you will not look for it.
## What it can do
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
seven concrete actions and creates them as sub-tasks. Written in your language.
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
line it suggests how you would have written it if you had finished it — project,
date, priority — with one sentence on what it read out of the task. Accept, edit, or
skip, one line at a time.
**Find the tasks in a note.** Open a note → *Find opgaver i noten*. Only what
somebody committed to or was asked to do; a heading is not a task and a topic that
was discussed is not a task. Each suggestion quotes the words it came from, so a
wrong one can be seen through rather than merely being wrong. The note itself is
never edited — the task carries `[[the note's title]]` in its description instead.
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
default; pick the hour yourself. One notification about what is due today and what
has been missed, and what to start with — and the same text as a card at the top of
**I dag**, where you are looking anyway when you start the day. The notification is
the reminder; the card is where it stays.
The card shows the plan *as it was sent*, with the time it was made, rather than a
new one each time you look: a plan that says something different when you look at it
than it said when it arrived is worse than either. **Forny planen** makes a new one
when the day has moved, and it does not send a notification about something you are
already looking at. With the morning notification off, the card offers to make one
on the spot.
The counts are done in the database and handed to the model as facts it may not
recompute — a plan that says four tasks where there are three is worse than no plan.
With no model configured the counts stand on their own, which is what you needed to
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
**Mail that becomes a task you can act on.** Where a
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
12"*, the model writes the action the mail asks for — and leaves the mail alone if
it asks for nothing. The subject stays in the description, so a mail it misread can
still be read. If the model is unavailable the task is named sender and subject as
before: a model that is down must not be able to stop the post.
**Ask your own notes and tasks.** ⌘K, type the question, ⌘⏎. *"Hvad lovede jeg
Anders i august?"* — search finds what contains the words, this answers the
question, and the answer links to the notes and tasks it rests on. If nothing
matches, it says so; a guess would be useless here, because you are asking precisely
because you cannot remember.
**Split a task into sub-tasks.** On any task: *Split with AI*. Two to seven concrete
actions, created as sub-tasks.
**A weekly summary.** A short note over what is outstanding: what looks urgent and
what appears to be slipping. At most six lines — it is a prompt to think, not a
report.
what appears to be slipping. At most six lines — a prompt to think, not a report.
## What is sent
Only what the feature needs: for a split, the task's title and notes; for a
summary, the titles, dates and priorities of your open tasks. Never comments,
attachments, or anything from a project the feature was not invoked on.
Only what the feature needs, and only when you invoke it:
Nothing is sent to anybody until you configure a provider. With a local model,
nothing leaves your machine at all.
| Feature | What leaves the instance |
| --- | --- |
| Tidy the inbox | The titles of the loose tasks in your inbox, the first words of their descriptions, and your project names |
| Tasks in a note | That one note's text, and your project names |
| Today's plan | The titles and dates of what is due or overdue today |
| Mail to task | Sender, subject and the snippet of the mails in that sweep |
| Ask | Your question, and the notes and tasks the search matched |
| Split a task | That task's title and notes |
| Weekly summary | The titles, dates and priorities of your open tasks |
Never comments, never attachments, never a project the feature was not invoked on,
and never anything belonging to somebody else. Nothing is sent to anybody until you
configure a provider, and with a local model nothing leaves your machine at all.
## Turning it off
+6 -5
View File
@@ -85,7 +85,7 @@ what a person reads is decided by the client, which knows what language they are
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
-H "Authorization: Bearer vrd_..." \
-H "Content-Type: application/json" \
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
```
### Projects and sections
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
| `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. |
@@ -108,7 +109,7 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| Method | Path | |
|---|---|---|
| `GET` | `/today` | Overdue and due today, in your timezone. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. Up to 31; over that is capped. |
| `GET` | `/search?q=` | Across everything you can see. |
| `GET` `POST` | `/filters` | |
| `GET` | `/filters/{id}/tasks` | Run one. |
+75
View File
@@ -0,0 +1,75 @@
# The install count
verdande reports that it exists. Two values, once a day:
```json
{
"instance_id": "01a0806d-3601-7000-ad76-057f1f684881",
"version": "v0.44.1"
}
```
That is the whole message. No IP is stored by the collector, no hostname, no
domain, no account, no counts of anything you have made, nothing about what you
use it for. The payload is small enough to be printed in full on the settings page,
and it is printed there — a promise about telemetry is worth exactly as much as
your ability to check it.
## On by default, off in one click
The alternative that was considered first was compulsory, and the argument against
it is practical rather than principled: it cannot be enforced. The domain can be
blocked, the binary can be patched, and a fork removes it in its first commit. So
mandatory buys nothing over on-by-default while costing the trust the rest of this
program works for.
Which is why the first administrator to sign in is **told, unprompted**, with the
real id, the real version and the real address in front of them, and two buttons.
On by default is only defensible if it is said out loud without being asked; a
settings page where it is stated fully is not the same thing, because that page is
opened by people who already suspect there is something to read.
**Settings → Data** afterwards: one switch. An explicit "off" survives upgrades.
!!! note "This disagrees with the update check, on purpose"
`VERDANDE_UPDATE_CHECK` is off unless you ask for it, and its reasoning is that
a self-hosted app reaching out unbidden has broken the deal its operator made by
self-hosting. That is the same situation as this one, and the two defaults do
not agree. The case for treating them differently: the update check reveals an
instance to a third party (GitHub) and returns something you can act on, while
this returns nothing and goes to the project. Whether that is a distinction or
an excuse is a judgement call, and it was made deliberately.
## What the collector cannot see, and what it can
A request has a source address whether or not anybody writes it down. The collector
stores none and logs none, and the code is arranged so the address never reaches a
function that could — the table has nowhere to put one. But the request passes
through a reverse proxy and a tunnel on the way, and those keep their own logs.
"We send no IP" would be a half-truth; what is true is that nothing here records
one.
The ping is not authenticated either, so anybody can post a made-up id and inflate
the count. That is an accepted trade: a token would have to ship inside every
install anyway.
## Running your own count
Somebody running a fleet points **Settings → Data → Modtagerens adresse** at their
own instance, and ticks **Denne instans er tælleren** there. Until that is ticked,
`POST /api/v1/beacon` answers **404** rather than accepting quietly — an instance
nobody made a collector must not become a bucket anyone can post into.
The collector's page then shows totals, active in 7 and 30 days, and a breakdown by
version. Publishing that number at `/api/v1/beacon/count` is a second, separate
opt-in, and it has a floor under it (25 by default): a number that *falls* cannot be
published as a fact — a bad release, a DNS change that quietly stops the pings, or
lost rows all look like "fewer people use this now" — and a small number mostly says
how little it takes to move it.
## When it cannot reach
The reason is written down and shown on the settings page. A beacon that cannot
reach its collector otherwise fails in complete silence: it tries again tomorrow,
the page says "last sent" with a date that quietly gets older, and nobody notices.
+7 -35
View File
@@ -1,9 +1,10 @@
# Calendar and CalDAV
Two ways to get your tasks into a calendar, one way to get a calendar into
verdande, and none of the three is the same thing.
Two ways to get your tasks into a calendar. Getting somebody else's calendar
*into* verdande is the other direction and has its own page — see
[Calendar](calendar.md).
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Calendars in](calendar.md) |
|---|---|---|---|
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
@@ -42,39 +43,10 @@ most people actually keep.
## Showing a Google calendar in verdande
The two sections above send your tasks *out*. This is the other direction: your
Google calendar drawn underneath them, so one screen answers "what does this day
already have in it".
Moved. Connecting a Google account, subscribing to an address, and what the grid
does with either now live on their own page — see [Calendar](calendar.md).
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
own colour for each one, beside your own tasks with due dates.
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
second redirect URI registered — see [Configuration](configuration.md#google).
!!! warning "Read only"
verdande never writes to your Google calendar. An event cannot be dragged,
edited or ticked off here; clicking one opens it in Google, which is where it
can be changed. Your own tasks in the same grid still drag to another day as
they always have.
Writing would need the `calendar.events` scope and an answer to what should
happen when both sides moved the same meeting — which is a synchronisation
model rather than a scope, and it is deliberately not built yet.
!!! note "The Internal client applies here too"
An OAuth client registered as *Internal* in a Google Workspace can only be
used by accounts in that workspace, calendars included. A personal
`@gmail.com` address is refused with `org_internal`. For one personal account
the shorter road is the calendar's own secret address: in Google Calendar,
**Settings → the calendar → Integrate calendar → Secret address in iCal
format**. Nothing in verdande subscribes to one yet — it is the next piece of
this feature.
Events are kept for ninety days back and a year forward, refreshed every fifteen
minutes and whenever you press **Fetch now**. Paging the grid past that window
says so rather than showing an empty month.
This page is the other direction: verdande's own tasks, read by something else.
## Connecting a CalDAV client
+91
View File
@@ -0,0 +1,91 @@
# Calendar
Your dated tasks as a grid, with other people's calendars laid under them.
**Calendar** in the menu shows a month or a week. The week is a day: hours down the
side, and everything with a time on it in its place. Two things at ten o'clock sit
beside each other, because that is a clash you should be able to see rather than
read.
## What is in it
- **Your tasks**, on the day they are due — and at the time, if they have one.
- **Events from calendars you have connected**, read-only.
Drag a task to another day to move it. Drop it on the hour column and it takes that
time; drop it in the band across the top and it keeps the day and loses the time.
Events cannot be dragged: verdande holds a copy of somebody else's calendar, and a
chip that lets itself be moved is a promise the server cannot keep.
A project's own calendar is under **Calendar** on the project page, with the same
week and month.
## Subscribing to a calendar
**Settings → Integrations → Subscriptions.** Paste an address and it is fetched at
once.
This works with anything that publishes an `.ics` file: public holidays, a club's
fixtures, a shared calendar from work, your own calendar's secret address from
Google, Apple or Fastmail.
- **`webcal://` addresses work.** They are the same URL over https with a scheme
that makes a desktop hand it to the calendar app; it is rewritten for you.
- **Several at a time.** Unlike the Google connection, which is one account, a
subscription is one address and you can have as many as you like.
- **Read-only, refreshed every fifteen minutes.**
- The address is fetched once before it is stored, so a typo tells you now rather
than by silently never filling.
!!! note "This is the way in for a private account"
If the instance's Google registration is *Internal*, Google refuses accounts
from outside the organisation with `org_internal` — no setting on your side
changes that. Your calendar's own secret ICS address goes through this panel
and works regardless.
## Connecting Google Calendar
**Settings → Integrations → Google Calendar.** It shares the OAuth client Gmail
uses, so the instance needs that registration first — see
[Configuration](configuration.md#google).
Two things have to be true in Google Cloud, and only the owner of the registration
can do them:
1. **Google Calendar API** is enabled for the project.
2. The calendar callback is listed as an authorised redirect URI. The exact string
is printed in the panel — copy it from there rather than typing it. Google
compares it exactly: scheme, host, path, no trailing slash. `redirect_uri_mismatch`
almost always means one of those four differs.
Once connected, tick the calendars you want to see. The list is refreshed on every
sync, so a calendar added or renamed since last time appears under its new name.
!!! warning "Written to the specification, not yet proven against Google"
The Google Calendar half is implemented from Google's documentation and tested
against a stand-in server. It has not been exercised against Google itself.
The subscription route above and the outgoing feed below have.
## Your own calendar, elsewhere
Two ways out, for two different jobs:
- **[The ICS feed](caldav.md#subscribing-to-a-feed)** — a read-only address for Apple Calendar,
Google or Thunderbird. Your tasks appear as dated entries.
- **[CalDAV](caldav.md)** — two-way. Tick something off in Apple Reminders and it
is ticked off here.
The feed address is a credential in itself: anybody holding it can read your tasks
without logging in, because a calendar client cannot sign in. Rotate it from
**Settings → Integrations** if it gets out.
## What it does not do
- **It does not write to a connected calendar.** Events are a copy.
- **It does not expand repeating events** from a subscribed file. A weekly meeting
in somebody else's calendar is stored as its first occurrence.
- **It does not resolve named time zones** from a subscribed file. An event carrying
`TZID=Europe/Copenhagen` shows the clock the file wrote, rather than one this
server has moved.
- **Only Google, and only reading**, for account connections. Everything else goes
through a subscription.
+27 -2
View File
@@ -12,6 +12,25 @@ nowhere to put one that the operator would ever see.
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
## Behind a proxy
verdande keys its login rate limit and its audit log on the caller's address, so
that address has to be one the caller cannot forge. A forwarded-for header is
believed only when the machine that connected is a trusted proxy; a connection
straight off the internet is keyed on the address it really came from.
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
!!! warning "A proxy that does not overwrite the header"
The default is safe when your proxy replaces a client-supplied
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
do. If yours passes the client's own header through, narrow
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
choose the address they are rate-limited and logged as.
## Sessions and links
| Variable | Default | What it does |
@@ -55,7 +74,7 @@ Then enable the APIs and scopes for whichever you want:
| For | Enable | Scope |
|---|---|---|
| Mailboxes | Gmail API | `gmail.readonly` |
| Mailboxes | Gmail API | `gmail.modify` |
| Calendar | Google Calendar API | `calendar.readonly` |
| Variable | What it does |
@@ -108,11 +127,17 @@ next time each value is written, so turning this on costs nobody a reconnection.
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists, and tell the administrators when one does. |
| `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. |
With the check on, administrators are told **once per version** — in the bell, in
push and on the app's badge — rather than having to open the settings page and
look. The notification leads to the page where the restart button is. With the
check off nothing is fetched and nothing is said; see
[Notifications](notifications.md).
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
+2 -2
View File
@@ -1,7 +1,7 @@
# Saved filters
A filter is a small expression describing a slice of your tasks. The grammar is
Todoist's, so if you are coming from there you already know it.
A filter is a small expression describing a slice of your tasks. The grammar will
feel familiar if you have used query-based filters in other task apps.
```
today & p1
+60 -12
View File
@@ -11,28 +11,64 @@ of. Which is what a to-do list is.
## What it is for
Todoist Pro, on your own hardware, with your own data. And with the integrations a
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
lists — on your own hardware, with your own data. And with the integrations a
hosted product will not build for you: a real CalDAV server, an address you can
forward mail to, and an MCP endpoint so Claude can work your task list directly.
## What it does
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
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.
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
shared by filing it in a project; `#project` and `[[another note]]` are links,
The calendar draws each task to its length — drag the foot to resize, the body
to move it.
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
settings themselves, by what they *do* rather than only by what they are called,
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
settings pages carry the same field at the top of their own list.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
single note with a person — by name, by email address, or by writing `@Sofie` in
the text — or file it in a project; `#project` and `[[another note]]` are links,
and a task shows what has been written about it.
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
greyed, at the foot of the list until a moment you pick — without touching when
it is due.
- **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.
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
account here you pick by name — the accounts on an instance are a closed,
invited set, so they are an address book rather than a directory to protect.
Anyone outside your instance joins through an invite link, and the invitation
stays visible until they arrive, so it does not get sent twice.
- **Live sync** — a change made by somebody else appears without a refresh.
- **Repeating tasks** as RRULE, reminders, comments and attachments.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
- **[Told when it matters](notifications.md).** A bell for what other people did to
what you share, pushed to your devices and counted on the app's icon in the Dock.
- **[AI that suggests and never writes](ai.md).** Optional, your own key. It tidies
the inbox, pulls the tasks out of a meeting note, writes a morning plan, turns
*"SV: SV: Vedr. levering uge 12"* into what the mail actually asks for, and
answers questions about your own notes with links to what the answer rests on —
every one of them as a line you accept, edit or refuse.
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
out as Web Push to an iPhone or a Mac.
- **[Anything can put a task in your inbox](mail.md)** — a mail to your own
address, or a POST from a shortcut on your phone, a script, or any service that
can call a URL.
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
comes off once it has, so a starred inbox empties as the tasks are made instead
of only filling up.
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
rather than private formats.
- **Six themes**, three dark and three light — including *Charcoal*, a warm
near-black after Apple Notes — and a size and typeface chosen apart from them.
- **Holds through a short network drop.** A save rides out a few seconds of no
signal and goes through when it comes back; a reload opens from what it last saw
rather than the browser's offline page. It installs as a PWA.
## What it looks like
@@ -48,7 +84,19 @@ columns.*
![Waiting on others](screenshots/delegated.png)
*What you have handed to somebody else, grouped by whoever has it.*
*What you have handed to somebody else, grouped by whoever has it — and each row
marked with an arrow to them, so a delegated task reads as delegated wherever you
meet it.*
![Notes in the Charcoal theme](screenshots/notes-charcoal.png)
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
amber where the accent green usually is.*
![The six themes as swatches](screenshots/themes.png)
*Six themes — three dark, three light — chosen on the settings page, with the
typeface and how tightly it sits set apart from them.*
![Settings, integrations](screenshots/settings.png)
+52
View File
@@ -0,0 +1,52 @@
# Opening links
A link in a task or a note opens in whichever browser your system opens links in.
**Settings → Links** can try to change that — and the honest description of what it
does is *try*.
## What a web page cannot do
Two things, and they set the limits of this feature:
- **It cannot see which browsers are installed.** The list is deliberately closed:
it would be a fingerprint, distinctive enough to follow somebody from site to
site.
- **It cannot ask the system to use a particular one.** A link goes to the default
browser, and there is no API that says otherwise.
The only handle that exists is the URL schemes browsers register for themselves —
`firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…` — and there is no
way to ask whether one exists. You can only try it.
## So the setting is an attempt
Pick Safari, Chrome, Firefox or Edge, or write your own scheme. When you click a
link, that scheme is tried first; if nothing has happened within about half a
second, the link opens the ordinary way.
The fallback is the whole reason this is safe to turn on. Without it, a setting
that works on your phone would leave links doing nothing on your desktop.
In practice: **it works on a phone**, where browsers register these schemes, and
**rarely on a Mac or a PC**, where Chrome and Firefox do not. Edge on Windows is
the usual exception. There is a **Prøv det** button for exactly this reason — you
cannot ask whether a scheme exists, so it is better to find out on example.dk than
on a link you meant to follow.
## Writing your own
Three placeholders, because browsers disagree about the shape they want the address
in:
| | |
| --- | --- |
| `{url}` | The address as it is — `x-safari-https://example.dk` |
| `{encoded}` | The address as a parameter value — `firefox://open-url?url=https%3A%2F%2F…` |
| `{stripped}` | The address without the `https://` — `googlechrome://example.dk` |
## It belongs to the machine, not the account
The choice is kept in the browser you are sitting at, not on your account — the
same rule the sidebar's width follows. `firefox://` works on the phone and does
nothing on the Mac, so a choice that followed the account would be wrong in one of
the two places every time.
+33
View File
@@ -67,3 +67,36 @@ immediately.
!!! tip "Keep the address to yourself"
Treat it like a secret. If you publish it somewhere, rotate it.
## Pushing from another program
The address above needs a mail server. When what you want is a shortcut on your
phone, a script, or any service that can call a URL, there is a second way in:
**Settings → Integrationer → Skub fra andre programmer** gives you an address to
POST a line of text to.
```bash
curl -X POST https://todo.example.dk/inbound/hook/<token> \
-H "Content-Type: text/plain" \
-d "Ring til Anders i morgen p1 #Firma"
```
The body is read three ways, because those are the three you meet: plain text from
a shortcut that just sends what was selected, JSON with a `text` field from a
service, or a form field from a webhook form. The first line becomes the task and
the rest sits underneath it, and the line goes through the [quick add](quick-add.md)
parser — so "i morgen", "p1" and "#Firma" mean here what they mean in the box at the
top of the app.
**The address is a key.** It has its own token rather than sharing the mail one:
the two are the same kind of secret but they leak separately, and a URL that has
been sitting in a shortcut on a lost phone has to be replaceable without changing
the address other people have in their address books. A token here can do one
thing — put a task in your inbox — which is why it is not an
[API token](api.md), which can do everything. Change it under the same setting; the
old one stops working at once.
**POST only.** A GET would be easier to call, and that is exactly the problem: a
browser prefetch, a link check in a chat, and a revisited history entry would all
create tasks nobody asked for.
+3 -3
View File
@@ -57,9 +57,9 @@ Both endpoints run the same server and reach the same things.
| `complete_task` | Tick one off. |
| `add_comment` | Comment on a task. |
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
labels and recurrence in either language.
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
projects, labels and recurrence in either language.
## What it can reach
+204
View File
@@ -0,0 +1,204 @@
# Notes
Markdown in, Markdown out. Nothing in between.
A note is written as rich text — headings, lists, quotations, code — and stored as
the Markdown it looks like. That is not a detail about the database: it is what
makes a note yours. Export gives you the same text you were looking at, and on the
day this program is gone, every note opens in any editor.
## Writing
Start typing. **The first line is the title** — always, and re-derived every time
you save, so a note you rewrite the top of is called by what it now says rather
than by what it once did.
| Style | Written as | Shortcut |
|---|---|---|
| Title | `# ` at the start of the line | pick it in **Aa** |
| Heading | `## ` | |
| Subheading | `### ` | |
| Quote | `> ` | |
| Code block | three backticks, with an optional language | |
| Bullet and numbered lists | `- ` and `1. `, nested with Tab | |
| Bold | two asterisks around the words | ⌘B |
| Italic | one asterisk around the words | ⌘I |
| Underline | a `u` tag around the words | ⌘U |
| Strikethrough | two tildes around the words | |
| Code | one backtick around the words | |
Every line becomes exactly one block. Markdown would normally join consecutive
lines into a paragraph; here it does not, because you typed a line and expect to
get it back.
Press the source button in the toolbar to see the note as the file it is. Edits there are edits to the
note.
### Pictures
Paste one, or drag it onto the note. It is stored with the note and travels with
it in an export. Every raster format works; SVG is stored but not shown inline —
it is a document that can run script, and a note is not a place to run somebody
else's.
**Click one to see it whole.** In the note it is scaled down to the width of the
column — a photo off a phone is four thousand pixels across — so what you are
looking at is a thumbnail that does not look like one. The full-size view has a
button that copies the picture to the clipboard, and a link that opens it in its
own tab. Escape closes it and puts the cursor back where it was.
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
little else, and a photo off a phone is a JPEG — without the conversion the copy
either fails or succeeds with something nothing will paste.
### Code blocks
A fenced block is coloured for the language it names, and guesses when it does not.
Each one carries a **Copy** button in its corner.
## Linking
Three kinds of reference, parsed out of the text as you write it:
| You type | It means |
|---|---|
| `#Firma` | The project — the same `#Firma` quick add understands, not a second kind of tag |
| `[[Møde med Anders]]` | Another note, by its title |
| A pasted task link | That task |
Links are an index over the text, rebuilt every time the note is saved. The text
stays the truth.
**The interesting direction is backwards.** A task shows what has been written
about it, and a project shows the notes filed in it — without anybody having to
remember to attach anything. Write `#Firma` in a note and it appears on that
project's page.
A link to something that has been deleted reads as a dead link. It is a fact about
the note, and it should not vanish because something it mentioned did.
## Filing and sharing
A note belongs to a project, or to nobody.
- **In a project:** everybody who can read the project can read the note, and an
editor can change it. Choose it under **Share in** at the foot of the note.
- **In no project:** yours, and nobody else's.
There is no folder tree. Projects, groups and labels already exist, are already
shared and already have roles — a second hierarchy to file things in is one too
many, and a note in a project inherits every one of those answers.
### Sharing one note with one person
Filing a note in a project is the right tool when the note belongs with a body of
work, and the wrong one when it is just *let Sofie see this*. **Del med personer**
at the foot of the note does the second: this note, this person, as reader or
editor. Only the note's owner can share it.
Type an **email address** rather than picking a name, and one of two things
happens. An address that already has an account is shared with straight away. One
that does not gets an invitation — a link that creates the account and the share
together, so the note is theirs the moment they sign up. Invitations wait in the
panel under the note, where they can be withdrawn; that is the only way back, since
only the token's hash is stored and a link sent to the wrong address cannot be
looked up.
### Naming somebody in a note
Writing `@Sofie` in a note shares it with her and tells her. Naming somebody in a
note *is* wanting them to read it, and doing it as two acts — write the name, then
open the panel and find the person — meant the second was forgotten, and the note
sat there addressing somebody who could not see it. The editor suggests names after
`@`, the same way `#` suggests projects.
- **Only the owner's mentions share.** An editor may write the name; giving
somebody else's note away is the owner's alone.
- **As a reader**, which is the least that satisfies "look at this". A role you
have raised in the panel is never lowered by a mention.
- **Only a name that has just appeared** counts. A note is saved on every typing
pause, so without that every save would be another share and another notification
about the same name.
## Finding
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
week**, and then by month. Sort by created, last touched or name, and the grouping
follows — sorted by name it groups by first letter instead.
Search finds a word wherever it is:
- **`gron` finds `grøn`.** Unicode does not consider `ø` an accented `o`, so
stripping diacritics is not enough for Danish. A folded copy of every note is
indexed beside the original.
- **`inventory` finds `keepInventory`.** An index is built of whole words, and a
decade of notes is full of commands and identifiers that are several words in
one. When the index has nothing, the text itself is read.
- Results are ranked by how well they match, with the title weighted ten times the
body. A word in the title is what the note is about; the same word once in a long
body is a mention.
## Putting notes away
Two different things, and the difference matters:
- **Archive** — this is finished and I do not want to read past it. It stays
findable and comes back whenever you ask.
- **Trash** — this was a mistake. It is gone in thirty days.
Select several with ⌘-click and shift-click to archive or delete them together.
## In and out
**Export** gives you a zip of Markdown files, one per note, with the dates in a
YAML front matter block that Obsidian and Bear also read:
```markdown
---
created: 2019-04-02T10:15:00Z
modified: 2024-11-30T21:04:00Z
---
# Møde med Anders
Han vil gerne have kaffe hver uge.
```
**Import** takes the same shape back. Pictures travel in the zip beside the notes,
and the links in the text are relative — so a note and its pictures must be in the
same archive, which matters when a large export is split into parts.
Two things to know before importing:
- **Nothing is merged.** Every run creates new notes. Importing the same archive
twice gives you two of everything.
- **The dates are read from the front matter.** Without them, a move of a thousand
notes is a pile that was all written the same evening — and the order they were
written in is half of what an archive is.
### From Apple Notes
`tools/apple-notes-til-markdown.py` in the repository exports Apple Notes to
exactly this shape, front matter and pictures included. It is macOS-only, because
Apple Notes is.
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
uploadable parts, keeping each note with its own pictures.
## What is encrypted, and what is not
**A note's title and body are sealed** in the database, under a key kept in a file
beside it rather than inside it. A backup you download does not carry the key, so
it does not carry your notes.
Both halves, not only the body: a note's title is its first line, so a readable
title beside a sealed body would leave the beginning of every note in the open.
**What is still in the clear:** task titles, descriptions and comments — and the
list of what a note links to, so a stolen file shows which projects and tasks a
note points at, though not what it says.
Sealing the notes cost the search its index — a full-text index over a note's body
is another copy of that body — so search reads the notes themselves now. At twelve
hundred notes that is about forty milliseconds, and the box waits a quarter of a
second after you stop typing, so you will not see it.
+52
View File
@@ -0,0 +1,52 @@
# Notifications
What other people did that you need to know about, and what the day has in it.
Verdande tells you in three places at once, and they are the same thing said three
ways: the **bell** in the header, a **push** to your devices, and the **badge** on
the app's icon.
## What causes one
| | |
| --- | --- |
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
| Somebody shared a note with you | Once, when it happens |
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
| Somebody gave you a task | Only on the change, and only to the person who got it |
| Somebody commented | |
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** |
| A new version is out | Administrators only, once per version |
Not your own actions: the server does not tell anybody what they just did
themselves. And not everything that happens — a bell that rings at every change in
a shared project is a bell people stop looking at.
**Repeat edits are folded.** A note is saved on every typing pause, so one person
writing for five minutes would otherwise be twenty identical lines. While the
message is unread, a further edit moves it back to the top and says who touched it
last. Once you have read it, the next edit is a new thing to be told.
## Push, and the badge
**Settings → Beskeder** turns on Web Push for the device you are sitting at, per
device: the phone in your pocket and the laptop you have open are different
answers, and a permission granted on one is not the other's.
The **badge** — the number on the icon in the Dock on a Mac, on the taskbar on a
PC — needs the app to be installed (added to the Dock, or installed as an app in
Chrome or Edge). Half the point of being told is being told while you are doing
something else, and the icon is the only place the app can say anything then. It
counts unread messages and goes down by itself as you read them. Firefox does not
support it; nothing else changes there.
The sentence you see is written in your browser from *what happened* and *who did
it*, so an English interface gets an English bell. The server's own wording is
Danish and is used only for push, which has no dictionary to look a phrase up in.
## Telling you about a new version
If the [update check](configuration.md) is on, administrators get one notification
per version — never one a day about the same one — and it leads to
**Settings → Beskeder**, where the button that restarts the instance to update it
sits. With the check off, nothing is fetched and nothing is said.
+866 -12
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -3,11 +3,11 @@
One line in, a fully-formed task out.
```
betal moms i morgen kl 10 p1 #Firma @regnskab
file the VAT return tomorrow at 10am p1 #Accounts @tax
```
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
Firma project, labelled regnskab.
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
in the Accounts project, labelled tax.
The parts it understands are tinted underneath the text as you type, so you can see
it being read before you commit to it.
@@ -16,8 +16,8 @@ it being read before you commit to it.
| You type | It means |
|---|---|
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
| `@regnskab` | Add that label. As many as you like. |
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
| `@tax` | Add that label. As many as you like. |
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
| `mandag`, `på fredag` | The next such weekday. |
+26 -8
View File
@@ -7,14 +7,14 @@ verdande ships with a Rune manifest for
```
create container: Error response from daemon:
No such image: ghcr.io/kristianwind/verdande:latest
No such image: gitea.nolimit.dk/kw/verdande:latest
```
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
The usual cause is a **private** 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.
@@ -24,10 +24,28 @@ looks locally — then reports the image as missing.
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.
**The package has to be readable without credentials**, and the only way to know
is to ask the registry for a token as a stranger:
```bash
curl -s "https://ghcr.io/token?scope=repository:kristianwind/verdande:pull&service=ghcr.io"
```
A 200 with a `token` in it means anyone can pull, which means the panel can. A
401 means it cannot, whatever the package page says.
!!! danger "Do not test this with `docker --config /tmp/empty pull`"
It looks like the honest test and is not. An empty config directory does not
stop the machine's credential helper, so the pull is quietly authenticated
with credentials you forgot you had — and reports success for an image no
stranger can fetch. This cost a production instance an hour of downtime: the
image was declared public on that evidence, the rune was pointed at it, and
the host — which had never logged in — could not pull a thing.
A self-hosted Gitea is where this bites. An instance with `REQUIRE_SIGNIN_VIEW`
turned on refuses anonymous tokens for its registry too, and no per-package
setting overrides it. Making the source repository public does not change it
either; that was tried.
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
@@ -37,8 +55,8 @@ 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
sudo docker login ghcr.io -u kristianwind
sudo docker pull ghcr.io/kristianwind/verdande:latest
sudo docker login gitea.nolimit.dk -u kw
sudo docker pull gitea.nolimit.dk/kw/verdande:latest
```
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 220 KiB

+8 -6
View File
@@ -1,14 +1,16 @@
# Self-hosting
# With Docker
verdande is one static binary and one directory. There is no database server, no
cache, no queue and no second process.
Running it yourself outside a panel: one static binary and one directory. There is
no database server, no cache, no queue and no second process. (Yggdrasil is
self-hosting too — for that route see [As a Rune](rune.md).)
## Docker
!!! 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 image is published from a self-hosted Gitea and is readable without
credentials — the package is not linked to a repository, so its visibility
follows the account rather than the (private) source repo. If it is ever set
back to private, `docker login gitea.nolimit.dk` 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.
+32
View File
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## Being let into a project
Two different situations, one panel — **the project's ⋯ menu → Share**.
Somebody who **already has an account here** you pick from a list by name. You do
not need to know their email address to share a project with a colleague whose
name you know. The accounts on an instance are a closed set — there is no open
signup, every one of them was invited — so listing them to each other is the
address book the feature needs, not a disclosure.
Somebody who is **not here yet** you invite by email. If the address turns out to
belong to an account it becomes an ordinary share straight away; otherwise it
becomes a link that creates their account and their membership at once. With no
mail server configured the link is shown in the panel instead of being sent, which
is the correct behaviour on a one-person instance rather than an error.
**You are told, and you can leave.** Being added to a project is not something
that happens to you quietly: the bell says who shared what, and the entry takes
you to the project. In the share panel, anybody who is not the owner has a *Leave
the project* button. That is deliberate rather than an approval step — an
invitation waiting on a yes means the person who shared thinks it is done while
the work sits still. What was wrong was the other three-quarters of it: you were
added without being asked, without being told, and with no way out except asking
the owner. The owner cannot leave their own project; that is a transfer of
ownership, and there is no transfer yet.
An invitation that has not been taken up stays listed under the members, greyed,
with a × to withdraw it. Without that it is invisible — the person has no account,
so they are neither a member nor somebody you can pick — and the usual next move
is to send it a second time. The second link works exactly as well as the first,
and then there are two to keep track of.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
+2 -2
View File
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
@@ -14,9 +16,7 @@ require (
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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
+275 -15
View File
@@ -47,6 +47,12 @@ type Config struct {
// ProviderCompatible and useful for a proxy in front of the others.
BaseURL string
Model string
// AllowPrivate lets BaseURL be on this machine or its private network.
//
// Set by the caller from who is asking, never read from storage: it is the
// answer to "may this person reach the LAN", and that answer can change after
// the address was saved. See the handler for who gets it and why.
AllowPrivate bool
}
func (c Config) Configured() bool {
@@ -73,13 +79,17 @@ type Client struct {
}
func New(cfg Config) *Client {
dial := safedial.Client
if cfg.AllowPrivate {
dial = safedial.AllowPrivate
}
return &Client{
cfg: cfg,
// Generous: a local model on modest hardware can take a while to produce
// its first token, and the alternative is a timeout that only ever fires
// for the people running their own.
// safedial, not a plain client. `base_url` is typed into a field in the
// interface, so it is a user-supplied address — and a server that fetches a
// safedial by default. `base_url` is typed into a field in the interface,
// so it is a user-supplied address — and a server that fetches a
// user-supplied address is a way to ask it to reach things the caller
// cannot: the panel next door, a database on the same bridge, the cloud
// metadata endpoint. On a homelab that is most of what is worth reaching.
@@ -87,7 +97,13 @@ func New(cfg Config) *Client {
// The check is on the resolved address rather than on the URL, because a
// name answers whatever its owner says — and can answer differently the
// second time, after a parse-time check has passed.
http: safedial.Client(120 * time.Second),
//
// And then the exception, which is the whole reason this provider exists:
// a model you run yourself IS on that network. Blocking it made the two
// lines above true and the feature useless — the comment about a local
// model being slow to its first token was written for a case the dialler
// refused to reach. AllowPrivate is granted per request, not per address.
http: dial(120 * time.Second),
}
}
@@ -379,22 +395,266 @@ back, do not congratulate, and do not offer productivity advice.`, language)
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
}
// Suggestion er én foreslået opgave, skrevet som en linje, man selv kunne have
// tastet.
//
// `line` er quick-add-syntaks — "Ring til Anders i morgen p1 #Firma" — og ikke et
// felt pr. egenskab. To grunde, og den anden er den vigtige. Parseren findes
// allerede og er den samme, uanset hvor teksten kom fra, så der er ingen anden
// fortolkning at holde i takt med den. Og et forslag, der er skrevet i det sprog,
// man selv skriver i, kan læses og rettes af den, det bliver stillet til — hvor
// et sæt felter, en model har udfyldt, er noget, man skal tage eller lade være.
//
// `why` er én sætning om, hvorfor den ser sådan ud. Den står ved siden af
// forslaget, så et forkert gæt kan gennemskues frem for bare at være forkert.
type Suggestion struct {
Line string `json:"line"`
Why string `json:"why,omitempty"`
}
// TidyInbox foreslår, hvordan løse opgaver i indbakken kunne skrives færdig.
//
// Modellen får de projekter, der findes, og må kun bruge dem: et `#Nyt Projekt`,
// den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja.
func (c *Client) TidyInbox(ctx context.Context, tasks, projects []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
known := "none"
if len(projects) > 0 {
known = "#" + strings.Join(projects, ", #")
}
system := fmt.Sprintf(`You tidy up a task inbox.
For each task you are given, write the line the person would have typed if they
had filed it properly. Keep their own words for what the task is — you are filing
it, not rewriting it.
The line may carry, in this syntax and no other:
#Project one of these existing projects, and never one that is not: %s
p1..p4 priority, where p1 is most urgent
a date in plain %s, such as "i morgen", "on friday", "15/3"
Leave out anything the task does not tell you. A guessed deadline is worse than
none: the person can add one, but they cannot see a wrong one you invented.
Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "...", "why": "..."} where why is at most one short sentence in %s
saying what you read out of the task. Give one element per task, in the order you
were given them, and leave a task out entirely if it is already filed well.`,
known, language, today, language)
prompt := "Tasks:\n" + strings.Join(tasks, "\n")
return c.suggestions(ctx, system, prompt)
}
// ActionsInNote trækker det, nogen har lovet, ud af en note.
//
// Beskrivelsen er stram med vilje. En model, der får lov at "finde opgaver" i en
// tekst, finder også emner, overskrifter og gode idéer — og så er resultatet en
// liste, man skal luge i, hvilket er dyrere end at skrive de tre punkter selv.
func (c *Client) ActionsInNote(ctx context.Context, note string, projects []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
known := "none"
if len(projects) > 0 {
known = "#" + strings.Join(projects, ", #")
}
system := fmt.Sprintf(`You read a note and pull out what somebody has to do.
Only things somebody committed to or was asked to do. A heading is not a task, a
topic that was discussed is not a task, and an idea nobody agreed to is not a
task. If the note contains none, reply with an empty array — that is a useful
answer and a made-up list is not.
Write each one as the line the person would have typed, in %s, in this syntax and
no other:
#Project one of these existing projects, and never one that is not: %s
p1..p4 priority, where p1 is most urgent
a date in plain %s, only when the note actually gives one
Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "...", "why": "..."} where why quotes the few words in the note that
the task came from, so it can be checked.`, language, known, language, today)
return c.suggestions(ctx, system, "Note:\n"+note)
}
// DailyPlan skriver morgenens to-tre linjer.
//
// Tallene er talt i basen og gives med som kendsgerninger, modellen ikke må regne
// om. En plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan —
// og en model, der tæller en liste, tæller den forkert af og til.
//
// Det ene sted, den må mene noget, er hvad man skulle begynde med, og hvad der
// kunne vente. Det er dét, der gør det til en plan frem for en optælling.
func (c *Client) DailyPlan(ctx context.Context, tasks []string, due, overdue int, locale string) (string, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You write somebody's morning note about their own day.
Write in %s. At most three short lines, no greeting, no sign-off, no
encouragement. Say what to start with and why. Name at most one thing that could
wait or be dropped, and only if something obviously can.
These numbers are counted for you and are facts: %d due today, %d overdue. Do not
recount them, do not contradict them, and do not list every task back.`,
language, due, overdue)
return c.Complete(ctx, system, []Message{
{Role: "user", Content: "The tasks:\n" + strings.Join(tasks, "\n")},
})
}
// Ask svarer på et spørgsmål ud fra de tekststykker, den får med — og kun dem.
//
// Svaret skal bære sine kilder, og det er derfor svaret er JSON og ikke prosa: en
// model, der skriver "[3]" midt i en sætning, gør det af og til, og en, der
// afleverer nummeret i et felt, gør det hver gang. Fladen skal kunne lave et link,
// ikke lede efter kantede parenteser i en tekst.
//
// "Det står der ikke" er et rigtigt svar. En model, der hellere gætter end
// skuffer, er ubrugelig til netop det her: man spørger om sine egne noter, fordi
// man ikke selv kan huske det, og har derfor ingen mulighed for at se, at svaret
// var opfundet.
func (c *Client) Ask(ctx context.Context, question string, passages []string, locale string) (string, []int, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You answer a question from somebody's own notes and tasks.
Use only the passages given. If they do not answer the question, say so plainly —
that is a useful answer, and a guess is not: the person is asking because they
cannot remember, so they cannot tell an invented answer from a real one.
Write in %s, at most four short lines. Reply with a JSON object and nothing else:
{"answer": "...", "sources": [1, 3]} where sources are the numbers of the
passages the answer actually rests on, and nothing else.`, language)
prompt := "Question: " + question + "\n\nPassages:\n" + strings.Join(passages, "\n\n")
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
if err != nil {
return "", nil, err
}
var out struct {
Answer string `json:"answer"`
Sources []int `json:"sources"`
}
if err := json.Unmarshal([]byte(extractObject(reply)), &out); err != nil {
// Et svar, der ikke er JSON, er stadig et svar. Teksten er det, personen
// spurgte om; henvisningerne er det, der går tabt, og det er en dårligere
// dag end en fejlmeddelelse ville være.
return strings.TrimSpace(reply), nil, nil
}
return strings.TrimSpace(out.Answer), out.Sources, nil
}
// TasksFromMail skriver én opgavelinje pr. mail.
//
// Emnefeltet er skrevet til at blive genkendt i en indbakke, ikke til at blive
// gjort noget ved: "SV: SV: Vedr. levering uge 12" siger, hvilken tråd det er, og
// intet om, hvad man skal. Modellen skriver den handling, mailen beder om — og
// lader være, hvis den ikke beder om nogen.
func (c *Client) TasksFromMail(ctx context.Context, mails []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You turn emails into the tasks they ask for.
For each numbered email, write the line the recipient would have typed for
themselves — what they have to DO, in %s, starting with a verb. Keep the sender's
name in it when it matters who it is about. Not a summary of the email: the
action it asks for.
The line may carry a date in plain %s, and p1..p4 for priority, but only when the
email itself says so — "before friday" in the text is a date, an email that merely
sounds urgent is not. Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "N. ..."} keeping the number the email was given, so the line can be
put back on the right one. Leave an email out entirely if it asks for nothing.`,
language, language, today)
return c.suggestions(ctx, system, "Emails:\n"+strings.Join(mails, "\n"))
}
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
// der ikke er en linje.
//
// Et tomt svar er ikke en fejl. "Der er ikke noget at foreslå" er et rigtigt svar
// på begge spørgsmål, og en fejlmeddelelse ville lære folk, at funktionen er i
// stykker, når den i virkeligheden var enig med dem.
func (c *Client) suggestions(ctx context.Context, system, prompt string) ([]Suggestion, error) {
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
if err != nil {
return nil, err
}
var out []Suggestion
if err := json.Unmarshal([]byte(extractJSON(reply)), &out); err != nil {
return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
}
clean := make([]Suggestion, 0, len(out))
for _, s := range out {
s.Line = strings.TrimSpace(s.Line)
s.Why = strings.TrimSpace(s.Why)
if s.Line != "" {
clean = append(clean, s)
}
}
return clean, nil
}
// languageOf er det sprog, svaret skal skrives i. Ét sted, fordi det ellers er
// den slags, der bliver rettet ét sted ud af fire.
func languageOf(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "English"
}
return "Danish"
}
// unfence tager svaret ud af den kodeblok, modeller pakker det ind i, uanset hvor
// omhyggeligt de bliver bedt om at lade være.
func unfence(reply string) string {
reply = strings.TrimSpace(reply)
fence := strings.Index(reply, "```")
if fence < 0 {
return reply
}
rest := reply[fence+3:]
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
rest = rest[newline+1:]
}
if end := strings.Index(rest, "```"); end >= 0 {
return strings.TrimSpace(rest[:end])
}
return reply
}
// extractObject er extractJSON for et objekt frem for en liste. Samme problem,
// samme løsning: modeller pakker svar ind i en kodeblok, hvor omhyggeligt de end
// bliver bedt om at lade være.
func extractObject(reply string) string {
reply = unfence(reply)
start := strings.IndexByte(reply, '{')
end := strings.LastIndexByte(reply, '}')
if start >= 0 && end > start {
return reply[start : end+1]
}
return reply
}
// extractJSON pulls an array out of a reply that may have been wrapped in a code
// fence or preceded by a sentence, which models do however firmly they are asked
// not to.
func extractJSON(reply string) string {
reply = strings.TrimSpace(reply)
if fence := strings.Index(reply, "```"); fence >= 0 {
rest := reply[fence+3:]
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
rest = rest[newline+1:]
}
if end := strings.Index(rest, "```"); end >= 0 {
reply = strings.TrimSpace(rest[:end])
}
}
reply = unfence(reply)
start := strings.IndexByte(reply, '[')
end := strings.LastIndexByte(reply, ']')
if start >= 0 && end > start {
+33
View File
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
}
}
// TOTPStep returns the step a code matched, and it must be the same step across
// the tolerance window — that number is what makes a code single-use, so a code
// entered a little early or late has to spend the same step, not a new one.
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
secret, _, err := NewTOTPSecret("verdande", "k@example.com")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
code := codeAt(t, secret, now)
want, err := TOTPStep(secret, code, now)
if err != nil {
t.Fatalf("the current code did not match: %v", err)
}
// Verified one step late — the code is still valid, and must report the step it
// was issued at, not the step it was checked at.
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
if err != nil {
t.Fatalf("code rejected one step late: %v", err)
}
if got != want {
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
}
if _, err := TOTPStep(secret, "000000", now); err == nil {
t.Error("a wrong code returned a step")
}
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
}
}
// codeAt produces the code an authenticator app would display at that moment,
// using the same settings VerifyTOTP checks against.
func codeAt(t *testing.T, secret string, now time.Time) string {
+71
View File
@@ -2,6 +2,7 @@ package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"errors"
"fmt"
@@ -9,6 +10,7 @@ import (
"strings"
"time"
"github.com/boombuler/barcode/qr"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
@@ -49,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
return key.Secret(), key.URL(), nil
}
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
// base64 blob to the page — one dark path on a white square, which is what a scanner
// needs whatever theme the page is in.
//
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
// this borrows a dependency rather than adding one.
func TOTPQRSVG(uri string) (string, error) {
code, err := qr.Encode(uri, qr.M, qr.Auto)
if err != nil {
return "", fmt.Errorf("auth: encode totp qr: %w", err)
}
b := code.Bounds()
n := b.Dx()
// A four-module quiet zone all round, which the spec requires and scanners rely
// on — without it the code runs to the edge and a reader cannot find its border.
const quiet = 4
size := n + quiet*2
var sb strings.Builder
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
// Dark modules are black; the red channel is 0 for those and full for the
// light ones. One 1×1 rect per dark module, merged into a single path.
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
}
}
}
sb.WriteString(`"/></svg>`)
return sb.String(), nil
}
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
// code again before enrolment is confirmed.
func TOTPURI(secret, issuer, account string) string {
@@ -82,6 +119,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
return nil
}
// TOTPStep verifies a code and returns the counter of the step it matched, so the
// caller can refuse to accept the same step twice.
//
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
// window is not enough to stop a replay: a code seen in flight stays valid for the
// rest of its period, and the fix is to record which step was spent and never
// accept that step or an earlier one again. That needs the number, so this walks
// the same window ValidateCustom would — one step either side — and reports the
// counter of the match. The comparison is constant time for the same reason the
// password check is: a code is a secret being checked against a computed value.
func TOTPStep(secret, code string, now time.Time) (int64, error) {
if secret == "" {
return 0, ErrNoTOTPSecret
}
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
Period: totpPeriod,
Skew: 0,
Digits: totpDigits,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return 0, ErrInvalidCode
}
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return at.Unix() / totpPeriod, nil
}
}
return 0, ErrInvalidCode
}
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
// and the hashes to store. Without these, losing a device means losing the account,
// and the only fix left is editing the database by hand.
+82
View File
@@ -7,6 +7,7 @@ package config
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strconv"
@@ -80,6 +81,26 @@ type Config struct {
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
// TrustedProxies are the peers whose forwarded-for header may be believed.
//
// The rate limiter and the audit log key on the caller's address, and that
// address is only as honest as wherever it was read. Read it from a header that
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
// every request and to write someone else's IP into the log. So the header is
// believed only when the machine that *connected* is one of these — a reverse
// proxy or tunnel the operator put there — and ignored when the connection came
// straight off the internet.
//
// The default is "the peer is on a private network", which is exactly the shape
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
// host or the docker bridge, so its address is loopback or RFC1918. A directly
// exposed instance sees a public peer, trusts nothing, and keys on the real
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
// or "none" to trust no proxy at all.
TrustedProxies []netip.Prefix
// RealIPHeader is where the client address is read from, for a trusted peer.
RealIPHeader string
Dev bool
}
@@ -149,9 +170,70 @@ func Load() (*Config, error) {
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
}
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
return nil, err
}
return c, nil
}
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
// families' link-local. A proxy an operator stands up in front of this server is
// on one of these; a stranger connecting directly is on none.
var defaultTrustedProxies = mustPrefixes(
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
"169.254.0.0/16", "fe80::/10",
)
// parseTrustedProxies reads the override. Empty keeps the private-network default;
// the literal "none" trusts no proxy at all, for an instance that is exposed
// directly and must never believe a forwarded header.
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultTrustedProxies, nil
}
if strings.EqualFold(raw, "none") {
return nil, nil
}
var out []netip.Prefix
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
// naming one proxy does not have to know CIDR notation to do it.
if !strings.Contains(part, "/") {
addr, err := netip.ParseAddr(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
continue
}
p, err := netip.ParsePrefix(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
}
out = append(out, p.Masked())
}
return out, nil
}
func mustPrefixes(cidrs ...string) []netip.Prefix {
out := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
out = append(out, netip.MustParsePrefix(c))
}
return out
}
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
+36 -9
View File
@@ -1,14 +1,20 @@
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
// it asks for.
//
// The connection is one-way on purpose: a starred or labelled message becomes a
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
// message does not delete the task, completing the task does not unstar the
// message. Two-way would mean deciding what to do when both sides changed, and the
// only honest answer to that is a synchronisation model far bigger than the feature.
// The one thing verdande writes back is the star. A starred message becomes a
// task, and once it has, the star comes off — exactly as the IMAP side takes the
// flag off a mail it has imported, and for the same reason: the star meant "turn
// this into a task", the task now exists, so the star is no longer true. Nothing
// else is touched. Completing the task does not reach back to the mail; the mail
// is a pointer the task was made from, not a copy the two have to be kept in step.
//
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
// asking for less is what makes the consent screen something a person can agree to.
// That one write is why the scope is gmail.modify rather than gmail.readonly.
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
// can do it — so the consent screen asks for more than it used to, and the trade
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
// than one that fills with stars nothing ever clears. Still not send, still not
// delete — modify is labels, and the only label verdande removes is the one that
// triggered the task.
//
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
// The names below are aliases so that every call site — and this package's own
@@ -27,8 +33,10 @@ import (
"github.com/kristianwind/verdande/internal/google"
)
// Read-only, and only mail. Not profile, not contacts, not send.
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
// Read mail and change its labels, and only mail. Not profile, not contacts, not
// send, not delete. The single write is removing the STARRED label from a message
// that has become a task; see the package comment for why that costs modify.
const Scope = "https://www.googleapis.com/auth/gmail.modify"
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
@@ -169,6 +177,25 @@ func (c *Client) get(ctx context.Context, url string, out any) error {
return google.Get(ctx, c.http, c.accessToken, url, out)
}
// Unstar takes the star off the given messages — one call for all of them, the way
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
// not ten.
//
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
// label would need one, and verdande removes none. batchModify answers 204 with no
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
// an error on Google's side and nothing worth making a round trip to hear.
func (c *Client) Unstar(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
body := struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
}
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
// falling back to the address when there is no display name.
func SenderName(from string) string {
+69 -4
View File
@@ -1,16 +1,81 @@
package gmail
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The scope is the one thing verdande chooses about the connection, and it is the
// one the person reads on the consent screen. The flow itself is exercised in
// internal/google; what belongs here is that Gmail still asks for read-only mail.
func TestTheScopeIsReadOnlyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.readonly") {
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
// internal/google; what belongs here is that Gmail asks for modify — read plus the
// one write it makes, taking the star off an imported message — and nothing wider.
func TestTheScopeIsModifyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.modify") {
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
}
// Modify is the ceiling. Send, or the full account, would be a different promise
// to the person on the consent screen than the one this feature needs.
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
if strings.Contains(Scope, wider) {
t.Errorf("scope = %q; must not include %q", Scope, wider)
}
}
}
// Unstar removes the star from a batch in one call, and it addresses the messages
// it was given and only the STARRED label.
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
var gotPath string
var gotBody struct {
IDs []string `json:"ids"`
AddLabelIDs []string `json:"addLabelIds"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
}))
defer srv.Close()
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
if err != nil {
t.Fatalf("Unstar: %v", err)
}
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
}
if strings.Join(gotBody.IDs, ",") != "a,b" {
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
}
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
}
if len(gotBody.AddLabelIDs) != 0 {
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
}
}
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
// there is nothing to say to it anyway.
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
defer srv.Close()
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
t.Fatalf("Unstar(nil): %v", err)
}
if called {
t.Error("Unstar(nil) made a request; it should make none")
}
}
+34
View File
@@ -215,6 +215,40 @@ func Get(ctx context.Context, client *http.Client, accessToken, url string, out
return json.NewDecoder(resp.Body).Decode(out)
}
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
// body coming back. A write call — modifying labels on a message — answers with
// 204 No Content when it worked, so anything 2xx is success and only the body is
// decoded, and only when the caller asked for one.
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("google: %s", resp.Status)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
+203
View File
@@ -0,0 +1,203 @@
package httpapi
import (
"net/http"
"sort"
"strings"
"time"
"github.com/kristianwind/verdande/internal/store"
)
// Spørg om sine egne noter og opgaver i almindeligt sprog.
//
// Søgningen finder det, der indeholder ordene. Det her svarer på spørgsmålet —
// "hvad lovede jeg Anders i august?" — og det er ikke det samme: svaret står
// spredt over tre noter og en opgave, og ingen af dem indeholder ordet "lovede".
//
// To halvdele, og rækkefølgen er hele sikkerheden i det. Basen finder kandidaterne
// med den søgning, der allerede findes, og som allerede kun kan se det, personen
// selv må se. Modellen får kun dem — den slår ikke selv noget op og kan ikke bede
// om mere. Et svar, der ikke står i det udleverede, kan derfor ikke handle om
// nogen andens noter.
//
// Og svaret bærer, hvad det er bygget på. En model, der svarer "det gjorde du den
// 14.", er kun brugbar, hvis man kan slå op i den note, den læste det i.
type askRequest struct {
Question string `json:"question"`
}
type askSourceJSON struct {
Kind string `json:"kind"` // "note" eller "task"
ID string `json:"id"`
Title string `json:"title"`
}
// handleAIAsk svarer på et spørgsmål ud fra personens egne noter og opgaver.
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
var req askRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
question := strings.TrimSpace(req.Question)
if question == "" {
writeFieldErrors(w, map[string]string{"question": "required"})
return
}
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
// Ord for ord, ikke hele spørgsmålet på én gang.
//
// Søgningen kræver, at *alle* ord optræder, og det er den rigtige regel for et
// søgefelt: skriver man to ord, mener man begge. Men et spørgsmål er ikke en
// søgning — "hvad lovede jeg Anders i august" indeholder ét ord, der står i
// noten, og fem, der ikke gør, så et krav om dem alle finder ingenting hver
// eneste gang.
//
// Så: et groft net her, og modellen læser fangsten. Alternativet — at lade
// modellen skrive søgningen — lægger et led mere ind, der kan tage fejl,
// mellem spørgsmålet og de noter, der faktisk findes.
notes, tasks, err := s.candidatesFor(r, user.ID, question)
if err != nil {
s.internal(w, r, "search for the question", err)
return
}
if len(notes) == 0 && len(tasks) == 0 {
writeJSON(w, http.StatusOK, map[string]any{
"answer": "", "sources": []askSourceJSON{},
})
return
}
// Nummereret, fordi svaret skal kunne pege tilbage. En henvisning til "noten
// om leveringen" er ikke noget, fladen kan lave et link ud af; et nummer er.
sources := make([]askSourceJSON, 0, len(notes)+len(tasks))
passages := make([]string, 0, len(notes)+len(tasks))
for _, n := range notes {
sources = append(sources, askSourceJSON{Kind: "note", ID: n.ID, Title: n.Title})
passages = append(passages, "["+itoaSource(len(sources))+"] Note: "+n.Title+"\n"+
firstWords(n.Body, 300))
}
for _, t := range tasks {
sources = append(sources, askSourceJSON{Kind: "task", ID: t.ID, Title: t.Content})
line := "[" + itoaSource(len(sources)) + "] Task: " + t.Content
if t.DueDate != "" {
line += " (" + t.DueDate + ")"
}
if t.Description != "" {
line += "\n" + firstWords(t.Description, 60)
}
passages = append(passages, line)
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
answer, used, err := client.Ask(ctx, question, passages, user.Locale)
if err != nil {
s.log.Warn("ai ask", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
// Kun de kilder, svaret faktisk brugte. Alle tolv ville gøre henvisningen
// værdiløs: en liste over alt, der blev kigget i, er ikke det samme som det,
// svaret står på.
cited := make([]askSourceJSON, 0, len(used))
for _, n := range used {
if n >= 1 && n <= len(sources) {
cited = append(cited, sources[n-1])
}
}
writeJSON(w, http.StatusOK, map[string]any{"answer": answer, "sources": cited})
}
func itoaSource(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
// candidatesFor henter det, spørgsmålet kunne handle om.
//
// Ét opslag pr. ord, lagt sammen og med det, der rammes af flere ord, øverst: et
// spørgsmål har ét eller to bærende ord og en håndfuld, der bare er dansk, og den
// note, der indeholder begge de bærende, er den, det handler om.
//
// Korte ord springes over. "i", "på" og "the" står i alt, og en note, der blev
// hentet, fordi den indeholder "i", fortrænger en, der faktisk svarer.
func (s *Server) candidatesFor(r *http.Request, userID, question string) ([]store.Note, []store.Task, error) {
terms := store.SearchTerms(question)
type scored struct {
note store.Note
task store.Task
hits int
}
noteHits := map[string]*scored{}
taskHits := map[string]*scored{}
for _, term := range terms {
if len([]rune(term)) < 3 {
continue
}
found, err := s.db.SearchNotes(r.Context(), term, 10)
if err != nil {
return nil, nil, err
}
for _, n := range found {
if at, ok := noteHits[n.ID]; ok {
at.hits++
continue
}
noteHits[n.ID] = &scored{note: n, hits: 1}
}
tasks, err := s.db.ListTasks(r.Context(), userID, store.TaskFilter{
Search: term, Limit: 10, IncludeCompleted: true,
})
if err != nil {
return nil, nil, err
}
for _, t := range tasks {
if at, ok := taskHits[t.ID]; ok {
at.hits++
continue
}
taskHits[t.ID] = &scored{task: t, hits: 1}
}
}
notes := make([]store.Note, 0, len(noteHits))
for _, at := range noteHits {
notes = append(notes, at.note)
}
sort.SliceStable(notes, func(i, j int) bool {
return noteHits[notes[i].ID].hits > noteHits[notes[j].ID].hits
})
tasks := make([]store.Task, 0, len(taskHits))
for _, at := range taskHits {
tasks = append(tasks, at.task)
}
sort.SliceStable(tasks, func(i, j int) bool {
return taskHits[tasks[i].ID].hits > taskHits[tasks[j].ID].hits
})
// Loftet er, hvad der kan stå i én prompt uden at koste mere, end svaret er
// værd — og hvad et menneske ville have gidet læse igennem selv.
return capped(notes, 12), capped(tasks, 20), nil
}
func capped[T any](in []T, n int) []T {
if len(in) > n {
return in[:n]
}
return in
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"context"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
)
// En mail, der bliver til en brugbar opgave.
//
// Uden det her hedder opgaven, hvad mailen hed: "Anders Jensen: SV: SV: Vedr.
// levering uge 12". Det er nok til at genkende den og ikke nok til at gøre noget
// ved den — og en indbakke af den slags linjer er præcis den bunke, man havde
// mail for at slippe for.
//
// Ét kald pr. gennemløb frem for ét pr. mail. Femogtyve mails er femogtyve gange
// ventetid og femogtyve gange betaling for det samme spørgsmål, og et gennemløb,
// der tager fem minutter, er et, der overlapper med det næste.
//
// Falder det ud, er der ikke sket noget: opgaven hedder afsender og emne, som den
// altid har gjort. En model, der er nede, må ikke kunne stoppe posten.
// mailForAI er én mail, som modellen får den at se.
type mailForAI struct {
From string
Subject string
Snippet string
}
// mailTaskLines beder modellen om én linje pr. mail, i quick-add-syntaks.
//
// Returnerer en tabel fra mailens plads i listen til linjen. Ikke en liste, fordi
// en model, der springer en mail over, ellers ville flytte alle de følgende
// linjer én plads — og så ville en opgave hedde noget, der stod i en anden mail.
// Det er den samme forsigtighed som ved oprydningen i indbakken, og den samme
// grund: en forskydning på én er den slags fejl, ingen opdager.
func (s *Server) mailTaskLines(ctx context.Context, user *store.User, mails []mailForAI) map[int]string {
if len(mails) == 0 {
return nil
}
cfg, err := s.aiConfigFor(ctx, user)
if err != nil || !cfg.Configured() {
return nil
}
lines := make([]string, 0, len(mails))
for i, m := range mails {
line := strconv.Itoa(i+1) + ". " + m.From + " — " + m.Subject
if m.Snippet != "" {
line += "\n " + firstWords(m.Snippet, 40)
}
lines = append(lines, line)
}
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
out, err := ai.New(cfg).TasksFromMail(ctx, lines, today, user.Locale)
if err != nil {
s.log.Info("mail tasks without the model", "err", err, "user", user.ID)
return nil
}
byIndex := map[int]string{}
for i, sug := range out {
n, line := numberedLine(sug.Line)
if n == 0 {
n = i + 1
}
if n < 1 || n > len(mails) || strings.TrimSpace(line) == "" {
continue
}
byIndex[n-1] = line
}
return byIndex
}
// applyMailLine lægger modellens linje ind i opgaven, hvis der er en.
//
// Linjen går gennem quick-add-parseren som alt andet, så en mail, hvor der står
// "inden fredag", får en dato — og "#Firma" i en linje, modellen fandt på, flytter
// ingenting, for projektet slås op blandt dem, der findes.
//
// Emnet bliver stående i beskrivelsen. Modellen har skrevet en titel ud fra det,
// og hvis den har misforstået mailen, skal det oprindelige stadig kunne læses ved
// siden af — ellers er den eneste vej tilbage at finde mailen frem igen.
func (s *Server) applyMailLine(ctx context.Context, user *store.User, task *store.Task, line, subject string) {
if strings.TrimSpace(line) == "" {
return
}
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
return
}
task.Content = parsed.Content
if parsed.Priority != 0 {
task.Priority = parsed.Priority
}
if parsed.DueDate != "" {
task.DueDate = parsed.DueDate
}
if parsed.Project != "" {
if id, err := s.db.ProjectByName(ctx, user.ID, parsed.Project); err == nil {
if _, err := store.RequireProjectRole(ctx, s.db, id, user.ID, store.RoleEditor); err == nil {
task.ProjectID = id
}
}
}
if subject != "" {
task.Description = strings.TrimSpace(subject + "\n\n" + task.Description)
}
}
+409
View File
@@ -0,0 +1,409 @@
package httpapi
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/store"
)
// Dagens plan: én besked om morgenen om det, der venter i dag.
//
// Klokken siger, hvad andre har gjort. Det her siger, hvad der ligger foran en —
// og det er en anden slags besked: den kommer, fordi det er blevet morgen, ikke
// fordi nogen har rørt noget. Uden den skal man selv åbne listen for at opdage,
// at der lå tre ting og forfaldt i går.
//
// Sendt som en besked frem for en mail, fordi røret allerede findes: klokken,
// push til telefonen og mærket på ikonet er ét kald, og en mail mere i indbakken
// er præcis det, dette program findes for at slippe for.
const planScope = "dailyplan"
// planSettings er, hvad én person har bedt om.
//
// Slået fra som udgangspunkt. En besked, man ikke har bedt om, klokken syv om
// morgenen, er ikke en hjælp — og modsat beaconet er der ingen, der har brug for,
// at det her er tændt for at kunne gøre sit arbejde.
type planSettings struct {
Enabled bool `json:"enabled"`
// Hour er lokal tid hos personen selv. Et tal og ikke et klokkeslæt: planen
// sendes på timen, og et minuttal ville love en præcision, en time-tikkende
// baggrundsjob ikke har.
Hour int `json:"hour"`
// LastSent er datoen, den sidst blev sendt, som "2006-01-02" i personens egen
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
LastSent string `json:"last_sent,omitempty"`
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
//
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
// andet end den besked, man fik i morges, er værre end begge dele hver for
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
// gæt hver gang man kigger.
//
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
Plan string `json:"plan,omitempty"`
PlanDate string `json:"plan_date,omitempty"`
PlanAt int64 `json:"plan_at,omitempty"`
}
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
out := planSettings{Hour: 7}
stored, err := s.db.UserSettings(ctx, userID, planScope)
if err != nil {
return out, err
}
if v, ok := stored["enabled"].(bool); ok {
out.Enabled = v
}
if v, ok := stored["hour"].(float64); ok && v >= 0 && v <= 23 {
out.Hour = int(v)
}
if v, ok := stored["last_sent"].(string); ok {
out.LastSent = v
}
if v, ok := stored["plan"].(string); ok {
out.Plan = v
}
if v, ok := stored["plan_date"].(string); ok {
out.PlanDate = v
}
if v, ok := stored["plan_at"].(float64); ok {
out.PlanAt = int64(v)
}
return out, nil
}
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
})
}
// handleGetPlanSettings er både indstillingen og dagens plan.
//
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
// to steder at huske at rydde en plan fra i går.
//
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
// et tomt kort.
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
p, err := s.planSettings(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
}
writeJSON(w, http.StatusOK, p)
}
type planRequest struct {
Enabled *bool `json:"enabled"`
Hour *int `json:"hour"`
}
func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
var req planRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
p, err := s.planSettings(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
if req.Enabled != nil {
p.Enabled = *req.Enabled
}
if req.Hour != nil {
if *req.Hour < 0 || *req.Hour > 23 {
writeFieldErrors(w, map[string]string{"hour": "must be between 0 and 23"})
return
}
p.Hour = *req.Hour
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu.
// Uden det her ville en, der flytter planen fra 07 til 16, først få den i
// morgen — hvilket ligner, at indstillingen ikke virkede.
p.LastSent = ""
}
if err := s.savePlanSettings(r.Context(), user.ID, p); err != nil {
s.internal(w, r, "save plan settings", err)
return
}
writeJSON(w, http.StatusOK, p)
}
type planNowRequest struct {
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
//
// En besked om noget, man står og ser på, er en besked, der lærer folk at
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
// hele dens formål: at vise, at push virker på det her apparat.
Silent bool `json:"silent"`
}
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
//
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
// lave en plan for den, der har beskeden slået fra.
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
var req planNowRequest
if r.ContentLength > 0 {
if err := decodeJSON(w, r, &req); err != nil {
return
}
}
user := userFrom(r.Context())
plan, err := s.buildPlan(r.Context(), user)
if err != nil {
s.internal(w, r, "build plan", err)
return
}
if plan == "" {
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
return
}
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
s.internal(w, r, "save plan", err)
return
}
sent := false
if !req.Silent {
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
sent = true
}
writeJSON(w, http.StatusOK, map[string]any{
"plan": plan, "sent": sent,
"plan_at": time.Now().Unix(),
})
}
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
p, err := s.planSettings(ctx, user.ID)
if err != nil {
return err
}
now := time.Now()
p.Plan = plan
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
p.PlanAt = now.Unix()
return s.savePlanSettings(ctx, user.ID, p)
}
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
// endnu i dag.
//
// Kaldt hver time frem for lagt på et klokkeslæt — samme form som backup og
// beacon, og af samme grund: en instans, der sov klokken syv, ville ellers
// springe dagen over. Og fordi klokken syv er syv forskellige steder: personens
// egen tidszone afgør, hvornår det er morgen hos dem.
func (s *Server) DailyPlans(ctx context.Context) error {
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
for _, u := range users {
user, err := s.db.UserByID(ctx, u.ID)
if err != nil {
continue
}
p, err := s.planSettings(ctx, user.ID)
if err != nil || !p.Enabled {
continue
}
now := time.Now().In(userLocation(user.Timezone))
today := now.Format("2006-01-02")
if p.LastSent == today || now.Hour() < p.Hour {
continue
}
// Kun samme dag. En instans, der har været slukket i tre dage, skal ikke
// sende tre planer, når den kommer op — de to af dem er om dage, der er
// gået, og en plan for i forgårs er ikke en plan.
if now.Hour() > p.Hour+3 {
p.LastSent = today
_ = s.savePlanSettings(ctx, user.ID, p)
continue
}
plan, err := s.buildPlan(ctx, user)
if err != nil {
s.log.Warn("build daily plan", "err", err, "user", user.ID)
continue
}
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god
// morgen, og en besked, der siger "der er ingenting", lærer folk at lade
// være med at åbne beskeder.
if plan != "" {
s.notifyDirect(ctx, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
// ene overskrive den andens `last_sent`.
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
}
p.LastSent = today
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
s.log.Warn("save plan settings", "err", err, "user", user.ID)
}
}
return nil
}
// buildPlan skriver dagens plan.
//
// Tallene kommer fra basen og sætningen fra modellen — i den rækkefølge. En model,
// der selv skulle tælle, ville af og til tælle forkert, og en plan, der siger fire
// opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model sat op,
// står tallene alene, og det er stadig det, man havde brug for at vide.
func (s *Server) buildPlan(ctx context.Context, user *store.User) (string, error) {
loc := userLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format("2006-01-02")
// I går, ikke i dag: grænserne i filteret er inklusive, så "før i dag" skrevet
// som DueBefore: today ville tælle dagens egne opgaver med som sprunget over —
// og en plan, der klokken syv om morgenen siger, at dagens opgaver allerede er
// overskredet, er en plan, man holder op med at læse.
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
overdue, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{DueBefore: yesterday, Limit: 50})
if err != nil {
return "", err
}
due, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
DueFrom: today, DueBefore: today, Limit: 50,
})
if err != nil {
return "", err
}
if len(overdue) == 0 && len(due) == 0 {
return "", nil
}
plain := plainPlan(len(due), len(overdue), user.Locale)
cfg, err := s.aiConfigFor(ctx, user)
if err != nil || !cfg.Configured() {
return plain, nil
}
lines := make([]string, 0, len(due)+len(overdue))
for _, t := range overdue {
lines = append(lines, "- "+t.Content+" ("+overdueWord(user.Locale)+" "+t.DueDate+")")
}
for _, t := range due {
lines = append(lines, "- "+t.Content)
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
written, err := ai.New(cfg).DailyPlan(ctx, lines, len(due), len(overdue), user.Locale)
if err != nil {
// Modellen er ikke det, planen står og falder med. Tallene er rigtige uden
// den, og en morgen uden besked, fordi en API-nøgle er udløbet, er en
// dårligere handel end en besked uden en velskrevet sætning.
s.log.Info("daily plan without the model", "err", err, "user", user.ID)
return plain, nil
}
return strings.TrimSpace(written), nil
}
// aiConfigFor er aiConfig uden en request bag sig — baggrundsjobbet har ingen.
//
// AllowPrivate er falsk her, og det er med vilje: retten til at nå det private
// net blev givet til en administrator, der sad og skrev adressen ind, og et job,
// der kører klokken syv om morgenen, er ikke nogen, der sidder og skriver. En
// model på maskinen selv skal derfor bruges gennem fladen, ikke af planen.
func (s *Server) aiConfigFor(ctx context.Context, user *store.User) (ai.Config, error) {
values, err := s.db.UserSettings(ctx, user.ID, "ai")
if err != nil {
return ai.Config{}, err
}
str := func(key string) string {
v, _ := values[key].(string)
return v
}
return ai.Config{
Provider: ai.Provider(str("provider")),
APIKey: str("api_key"),
BaseURL: str("base_url"),
Model: str("model"),
}, nil
}
// notifyDirect er notify uden en request. Baggrundsjobbet har ingen bruger i
// konteksten og ingen actor — planen er ikke noget, nogen har gjort ved en.
func (s *Server) notifyDirect(ctx context.Context, n *store.Notification) {
if err := s.db.CreateNotification(ctx, n); err != nil {
s.log.Warn("create notification", "err", err)
return
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, Kind: n.Kind, Title: n.Title, Body: n.Body,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
go s.pushToUser(n.UserID, n.Title, n.Body, "")
}
func planTitle(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "Today"
}
return "I dag"
}
func overdueWord(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "was due"
}
return "forfaldt"
}
// plainPlan er planen uden en model: to tal, og ikke et ord mere.
//
// Det er ikke en nødløsning, det er bunden. Det, man har brug for at vide klokken
// syv, er om der er noget, der er sprunget over — og det tal er rigtigt, uanset
// om nogen har lagt en API-nøgle ind.
func plainPlan(due, overdue int, locale string) string {
english := strings.HasPrefix(strings.ToLower(locale), "en")
var parts []string
if due > 0 {
if english {
parts = append(parts, strconv.Itoa(due)+" due today")
} else {
parts = append(parts, strconv.Itoa(due)+" forfalder i dag")
}
}
if overdue > 0 {
if english {
parts = append(parts, strconv.Itoa(overdue)+" overdue")
} else {
parts = append(parts, strconv.Itoa(overdue)+" er sprunget over")
}
}
return strings.Join(parts, ", ")
}
+157
View File
@@ -0,0 +1,157 @@
package httpapi
import (
"net/http"
"testing"
"time"
)
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
// hvor der er tre, er værre end ingen plan — så tallene tælles her, og modellen
// får dem udleveret som kendsgerninger.
func TestTheDailyPlanCountsForItself(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "ring til anders i dag")
ts.quickTask(t, "køb mælk i dag")
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("send planen: %d %v", resp.StatusCode, out)
}
if out["sent"] != true {
t.Fatalf("planen blev ikke sendt: %v", out)
}
// Uden en model står tallene alene, og det er stadig det, man havde brug for
// at vide klokken syv.
if plan, _ := out["plan"].(string); plan != "2 forfalder i dag" {
t.Errorf("plan = %q", plan)
}
// Og den lander i klokken som en besked.
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
if n := firstOf(t, list); n["kind"] != "daily.plan" {
t.Errorf("besked = %v", n)
}
}
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god morgen, og
// en besked, der siger "der er ingenting", lærer folk at lade være med at åbne
// beskeder.
func TestAnEmptyDaySendsNoPlan(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("send planen: %d", resp.StatusCode)
}
if out["sent"] != false {
t.Errorf("der blev sendt en plan om ingenting: %v", out)
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("beskeder = %d, want 0", len(list))
}
}
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu. Uden det
// ville en, der flytter planen fra 07 til 16, først få den i morgen — hvilket
// ligner, at indstillingen ikke virkede.
func TestMovingThePlanHourClearsTodaysMark(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{
"enabled": true, "hour": 7,
}); resp.StatusCode != http.StatusOK {
t.Fatal("slå planen til")
}
if _, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil); out["sent"] != true {
t.Fatalf("planen blev ikke sendt: %v", out)
}
resp, out := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 16})
if resp.StatusCode != http.StatusOK {
t.Fatalf("flyt timen: %d", resp.StatusCode)
}
if out["hour"] != float64(16) {
t.Errorf("time = %v", out["hour"])
}
if out["last_sent"] != nil && out["last_sent"] != "" {
t.Errorf("mærket for i dag blev stående: %v", out["last_sent"])
}
}
// En time, der ikke findes på et ur, er en fejl i feltet og ikke en 500.
func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 25}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
}
}
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
}
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("lav planen: %d", resp.StatusCode)
}
if made["sent"] != false {
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
}
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if after["plan"] != made["plan"] {
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
}
if after["plan_at"] == nil {
t.Error("planen bærer ikke, hvornår den blev lavet")
}
}
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
// overskriften "i dag", er værre end et tomt kort.
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
t.Fatal("lav planen")
}
// Sat en dag tilbage, som om den var lavet i går.
user := userID(t, ts, "kristian@example.dk")
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
if err != nil {
t.Fatal(err)
}
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
t.Fatal(err)
}
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if plan, _ := out["plan"].(string); plan != "" {
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
}
}
+370
View File
@@ -0,0 +1,370 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
)
// AI'ens forslag, og hvad der sker med dem.
//
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
// skal have ret tit nok til at spare et par tastetryk.
//
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
// gennem den samme som alt andet.
// aiSuggestionJSON er ét forslag på vej til fladen.
type aiSuggestionJSON struct {
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
TaskID string `json:"task_id,omitempty"`
Line string `json:"line"`
Why string `json:"why,omitempty"`
}
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "ai settings", err)
return nil, false
}
if !cfg.Configured() {
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
// lægger sin egen nøgle ind.
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
return nil, false
}
return ai.New(cfg), true
}
// projectNames er de projekter, modellen må nævne.
//
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
// nogen sagde ja til forslaget.
func (s *Server) projectNames(r *http.Request) []string {
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
if err != nil {
s.log.Warn("projects for ai", "err", err)
return nil
}
out := make([]string, 0, len(projects))
for _, p := range projects {
if p.IsInbox {
continue
}
out = append(out, p.Name)
}
return out
}
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
// skrives færdig.
//
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
// spørgsmål og et ja pr. linje.
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
inbox, err := s.db.InboxID(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "inbox", err)
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{inbox}, Limit: 50,
})
if err != nil {
s.internal(w, r, "list inbox", err)
return
}
if len(tasks) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
return
}
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
// må ikke kunne flytte alle de andres forslag én plads — derfor står
// nummeret i teksten og bliver læst tilbage.
lines := make([]string, 0, len(tasks))
for i, t := range tasks {
line := strconv.Itoa(i+1) + ". " + t.Content
if t.Description != "" {
line += " — " + firstWords(t.Description, 30)
}
lines = append(lines, line)
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
if err != nil {
s.log.Warn("ai tidy inbox", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
out := make([]aiSuggestionJSON, 0, len(suggestions))
for i, sug := range suggestions {
n, line := numberedLine(sug.Line)
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
if n == 0 {
n = i + 1
}
if n < 1 || n > len(tasks) {
continue
}
if strings.TrimSpace(line) == "" {
continue
}
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}
type applyLineRequest struct {
TaskID string `json:"task_id"`
Line string `json:"line"`
}
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
// om.
//
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
// der ikke blev rørt.
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
var req applyLineRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
if strings.TrimSpace(req.Line) == "" {
writeFieldErrors(w, map[string]string{"line": "required"})
return
}
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
loc := userLocation(user.Timezone)
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
return
}
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
// slette noget.
update := store.TaskUpdate{Content: &parsed.Content}
if parsed.Priority != 0 {
update.Priority = &parsed.Priority
}
if parsed.DueDate != "" {
update.SetDue, update.DueDate = true, parsed.DueDate
}
if parsed.Recurrence != "" {
update.RecurrenceRule = &parsed.Recurrence
}
if len(parsed.Labels) > 0 {
update.Labels, update.SetLabels = parsed.Labels, true
}
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
s.storeError(w, r, "update task", err)
return
}
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
s.internal(w, r, "move task", err)
return
}
}
}
}
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
}
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
//
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
// bare at være forkert.
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if strings.TrimSpace(n.Body) == "" {
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
return
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
if err != nil {
s.log.Warn("ai note actions", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
out := make([]aiSuggestionJSON, 0, len(suggestions))
for _, sug := range suggestions {
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}
// handleAICreateFromNote laver én accepteret opgave fra en note.
//
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
// den kom fra — og noten kan findes på sin titel.
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
var req applyLineRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
loc := userLocation(user.Timezone)
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
return
}
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
// linjen vinder, fordi det er valgt frem for arvet.
projectID := n.ProjectID
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
}
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, r, "inbox", err)
return
}
}
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
task := &store.Task{
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, r, "create task from note", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
}
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
//
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
// model, der springer en opgave over, ville så flytte alle de følgende forslag
// én plads, og så bliver "sæt dato på den her" sat på en anden.
func numberedLine(s string) (int, string) {
s = strings.TrimSpace(s)
dot := strings.IndexByte(s, '.')
if dot <= 0 || dot > 3 {
return 0, s
}
n := 0
for _, c := range s[:dot] {
if c < '0' || c > '9' {
return 0, s
}
n = n*10 + int(c-'0')
}
return n, strings.TrimSpace(s[dot+1:])
}
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
// fylde den med en hel note.
func firstWords(s string, n int) string {
fields := strings.Fields(s)
if len(fields) <= n {
return strings.Join(fields, " ")
}
return strings.Join(fields[:n], " ") + " …"
}
+234
View File
@@ -0,0 +1,234 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
//
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
func fakeModel(t *testing.T, reply string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]string{"content": reply}},
},
})
}))
t.Cleanup(srv.Close)
return srv.URL
}
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
// på en adresse i det private net, og bootstrap-kontoen er en.
func (ts *testServer) useModel(t *testing.T, url string) {
t.Helper()
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "base_url": url, "model": "prøvemodel",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
}
}
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
// anden opgave end den, den blev skrevet om.
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
]`))
first := ts.quickTask(t, "noget som helst")
second := ts.quickTask(t, "anders 12345678")
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
}
list, _ := out["suggestions"].([]any)
if len(list) != 1 {
t.Fatalf("forslag = %v, want ét", list)
}
sug := list[0].(map[string]any)
if sug["task_id"] != second {
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
}
if sug["line"] != "Ring til Anders i morgen p1" {
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
}
if sug["why"] == "" {
t.Error("forslaget siger ikke hvorfor")
}
_ = first
}
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[]`))
taskID := ts.quickTask(t, "anders")
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
"task_id": taskID, "line": "Ring til Anders i morgen p1",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
}
if out["content"] != "Ring til Anders" {
t.Errorf("indhold = %v", out["content"])
}
if out["priority"] != float64(1) {
t.Errorf("prioritet = %v, want 1", out["priority"])
}
if out["due_date"] == nil || out["due_date"] == "" {
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
}
}
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
// opgave hen, hvor ejeren ikke leder efter den.
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[]`))
taskID := ts.quickTask(t, "anders")
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("accepter: %d", resp.StatusCode)
}
if after["project_id"] != before["project_id"] {
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
}
}
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
// stole på.
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
]`))
body := "# Møde med Anders\njeg sender prislisten fredag"
noteID := ts.createNote(t, body)
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
}
list, _ := out["suggestions"].([]any)
if len(list) != 1 {
t.Fatalf("forslag = %v, want ét", list)
}
line := list[0].(map[string]any)["line"].(string)
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
map[string]any{"line": line})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
}
if task["content"] != "Send prisliste til Anders" {
t.Errorf("opgave = %v", task["content"])
}
// Opgaven bærer, hvor den kom fra, som et notelink.
if task["description"] != "[[Møde med Anders]]" {
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
}
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
if note["body"] != body {
t.Errorf("noten blev rettet:\n%v", note["body"])
}
}
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
// noget".
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
}
}
}
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
func (ts *testServer) quickTask(t *testing.T, text string) string {
t.Helper()
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
}
id, _ := out["id"].(string)
if id == "" {
t.Fatalf("quickadd gav intet id: %v", out)
}
return id
}
// Svaret på et spørgsmål bærer, hvad det står på — og kun det, det faktisk brugte.
// En liste over alt, der blev kigget i, er ikke en henvisning.
func TestAskingAnswersFromYourOwnNotes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `{"answer": "Du lovede Anders prislisten fredag.", "sources": [1]}`))
ts.createNote(t, "# Møde med Anders\njeg sender prislisten fredag")
ts.createNote(t, "# Noget helt andet\nanders er ikke nævnt her")
resp, out := ts.do(t, "POST", "/api/v1/ai/ask", map[string]any{"question": "hvad lovede jeg Anders?"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("spørg: %d %v", resp.StatusCode, out)
}
if out["answer"] != "Du lovede Anders prislisten fredag." {
t.Errorf("svar = %v", out["answer"])
}
sources, _ := out["sources"].([]any)
if len(sources) != 1 {
t.Fatalf("kilder = %v, want én", sources)
}
src := sources[0].(map[string]any)
if src["kind"] != "note" || src["title"] != "Møde med Anders" {
t.Errorf("kilde = %v", src)
}
}
// Et spørgsmål, der ikke rammer noget, får ikke et opfundet svar. Man spørger om
// sine egne noter, fordi man ikke selv kan huske det, og kan derfor ikke se, at
// svaret var gættet.
func TestAskingAboutNothingAnswersNothing(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `{"answer": "det burde du huske", "sources": []}`))
resp, out := ts.do(t, "POST", "/api/v1/ai/ask",
map[string]any{"question": "kvadratrodenafenbanan"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("spørg: %d", resp.StatusCode)
}
if out["answer"] != "" {
t.Errorf("der blev svaret uden noget at svare ud fra: %v", out["answer"])
}
}
+18 -1
View File
@@ -218,7 +218,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
return
}
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
if err == nil {
// The code is right; now it must also be unused. A TOTP code stays valid for
// its whole window, so one observed in flight — a phishing page, a proxy,
// over the shoulder — could otherwise be replayed until it expires. Spending
// the step here makes it single-use: a second presentation of the same code,
// or an earlier one, finds the step already gone.
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
if cerr != nil {
s.internal(w, r, "consume totp step", cerr)
return
}
if !fresh {
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"that verification code has already been used")
return
}
}
if err != nil {
// A recovery code is accepted in the same field. Making somebody find a
// different form while locked out of their account is a poor time to
+50 -1
View File
@@ -27,6 +27,9 @@ type testServer struct {
*httptest.Server
db *store.DB
client *http.Client
// api er den samme server, routeren hænger på. Et par prøver har brug for at
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
api *Server
}
func newTestServer(t *testing.T) *testServer {
@@ -62,7 +65,8 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
// Discard: a passing test should print nothing, and these handlers log freely.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(New(cfg, db, log, nil))
api := New(cfg, db, log, nil)
srv := httptest.NewServer(api)
t.Cleanup(srv.Close)
jar, err := cookiejar.New(nil)
@@ -75,6 +79,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
// The cookie jar is what makes this a session test rather than a
// sequence of unrelated requests.
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
api: api,
}
}
@@ -367,6 +372,50 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
}
}
// A TOTP code works once. A code seen in flight and replayed inside its window
// must be refused the second time, or the second factor is only as good as the
// thirty seconds an attacker has to reuse what they captured.
func TestTOTPCodeIsSingleUse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
secret, _ := body["secret"].(string)
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
// One code, captured once and presented twice.
code := codeFor(t, secret)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusOK {
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
}
// A second login, same code, same window.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
}
}
// A recovery code is accepted in the same field as a TOTP code, and works once.
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
ts := newTestServer(t)
+191 -13
View File
@@ -20,6 +20,11 @@ import (
const beaconScope = "beacon"
// defaultPublishMin er, hvor mange installationer der skal være, før tallet
// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
const defaultPublishMin = 25
// beaconConfig is what this instance has been told to do. The zero value is what
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
// not here — a zero struct must never mean "silently on".
@@ -30,6 +35,33 @@ type beaconConfig struct {
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt int64 `json:"last_ping_at,omitempty"`
// LastVersion er den version, der sidst nåede frem. Gemt, fordi en
// installation, der opdaterer sig selv om natten, ellers står som gårsdagens
// version i op til et døgn — og en tælling efter version, der er en dag bagud,
// kan man ikke se en udrulning i.
LastVersion string `json:"last_version,omitempty"`
// LastError er grunden til, at det sidste forsøg ikke nåede frem.
//
// Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
// collector, prøver igen i morgen og siger ingenting, og det opdages først ved
// at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
// der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
// alt er sendt.
LastError string `json:"last_error,omitempty"`
LastErrorAt int64 `json:"last_error_at,omitempty"`
// NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
//
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
// uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
// det, der husker, at det er blevet sagt.
NoticeAck bool `json:"notice_ack"`
// PublishMin er gulvet under det offentliggjorte tal.
//
// Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
// udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
// er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
// der ingenting frem for et lille tal, ingen kan tolke.
PublishMin int `json:"publish_min"`
}
// beaconSettings reads the configuration, filling in the defaults and minting the
@@ -40,7 +72,9 @@ type beaconConfig struct {
// also be a value the collector could compare against a guess — which is the
// difference between anonymous and pseudonymous, and it is the whole point.
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
cfg := beaconConfig{
Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
}
stored, err := s.db.InstanceSettings(ctx, beaconScope)
if err != nil {
@@ -65,6 +99,21 @@ func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
if v, ok := stored["last_ping_at"].(float64); ok {
cfg.LastPingAt = int64(v)
}
if v, ok := stored["last_version"].(string); ok {
cfg.LastVersion = v
}
if v, ok := stored["last_error"].(string); ok {
cfg.LastError = v
}
if v, ok := stored["last_error_at"].(float64); ok {
cfg.LastErrorAt = int64(v)
}
if v, ok := stored["notice_ack"].(bool); ok {
cfg.NoticeAck = v
}
if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
cfg.PublishMin = int(v)
}
if cfg.InstanceID == "" {
cfg.InstanceID = store.NewID()
@@ -83,20 +132,31 @@ func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error
"is_collector": cfg.IsCollector,
"publish_count": cfg.PublishCount,
"last_ping_at": cfg.LastPingAt,
"last_version": cfg.LastVersion,
"last_error": cfg.LastError,
"last_error_at": cfg.LastErrorAt,
"notice_ack": cfg.NoticeAck,
"publish_min": cfg.PublishMin,
})
}
// --- what the settings page shows ------------------------------------------------
type beaconStatusJSON struct {
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
Version string `json:"version"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt string `json:"last_ping_at,omitempty"`
Stats *store.BeaconStats `json:"stats,omitempty"`
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
Version string `json:"version"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
PublishMin int `json:"publish_min"`
LastPingAt string `json:"last_ping_at,omitempty"`
// Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
// frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
// det eneste spor, et tavst beacon ellers efterlader.
LastError string `json:"last_error,omitempty"`
LastErrorAt string `json:"last_error_at,omitempty"`
Stats *store.BeaconStats `json:"stats,omitempty"`
}
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
@@ -109,10 +169,15 @@ func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
out := beaconStatusJSON{
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
PublishMin: cfg.PublishMin,
}
if cfg.LastPingAt > 0 {
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
}
if cfg.LastError != "" {
out.LastError = cfg.LastError
out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
}
if cfg.IsCollector {
stats, err := s.db.BeaconSummary(r.Context())
if err != nil {
@@ -129,6 +194,7 @@ type beaconRequest struct {
Collector *string `json:"collector_url"`
IsCollector *bool `json:"is_collector"`
PublishCount *bool `json:"publish_count"`
PublishMin *int `json:"publish_min"`
}
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
@@ -151,6 +217,13 @@ func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
if req.PublishCount != nil {
cfg.PublishCount = *req.PublishCount
}
if req.PublishMin != nil {
if *req.PublishMin < 0 {
writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
return
}
cfg.PublishMin = *req.PublishMin
}
if req.Collector != nil {
url := strings.TrimSpace(*req.Collector)
if url == "" {
@@ -237,6 +310,16 @@ func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
}
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
if err == nil && n < cfg.PublishMin {
// Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
// hvor lidt der skal til at flytte det — og et, der falder, ville blive
// læst som "færre bruger det nu", også når det i virkeligheden var en
// udrulning, der gik galt, eller en adresse, der stille holdt op med at
// blive ramt.
w.Header().Set("Cache-Control", "public, max-age=60")
writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
return
}
if err != nil {
s.internal(w, r, "beacon count", err)
return
@@ -269,11 +352,15 @@ func (s *Server) SendBeacon(ctx context.Context) error {
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
return err
}
cfg.LastPingAt = time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
return s.beaconSent(ctx, cfg)
}
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
// Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
// opdaterer sig selv om natten og først melder ind igen et døgn senere, står
// som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
// tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
// et døgn efter at være opdateret.
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
return nil
}
@@ -289,9 +376,100 @@ func (s *Server) SendBeacon(ctx context.Context) error {
// installation's problem and must never look like one — it is somebody
// else's server, and the only consequence is a number being one lower.
s.log.Info("beacon not delivered", "err", err)
return nil
// Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
// collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
// "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
// Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
// savne en.
cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
}
return s.beaconSent(ctx, cfg)
}
// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
// siden om noget, der virker.
func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
cfg.LastPingAt = time.Now().Unix()
cfg.LastVersion = Version
cfg.LastError, cfg.LastErrorAt = "", 0
return s.saveBeaconSettings(ctx, cfg)
}
// --- beskeden, der bliver sagt uopfordret ------------------------------------------
// handleBeaconNotice says whether the disclosure is still owed, and what it would
// say.
//
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
// dem, der ved, at der er noget at slå fra.
//
// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
// løfte, der er noget værd: en, læseren selv kan kontrollere.
//
// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
// ikke kan svare på det.
func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"pending": !cfg.NoticeAck,
"enabled": cfg.Enabled,
"instance_id": cfg.InstanceID,
"version": Version,
"collector_url": cfg.Collector,
})
}
type beaconNoticeRequest struct {
// Keep er en peger og ikke en bool, og det er hele pointen.
//
// En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
// aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
// løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
// tilsvarende ende og er blevet bidt af den.
Keep *bool `json:"keep"`
}
// handleAckBeaconNotice records that the notice has been seen, and turns the
// beacon off if that was the answer.
//
// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
var req beaconNoticeRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Keep == nil {
writeFieldErrors(w, map[string]string{"keep": "required"})
return
}
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
cfg.NoticeAck = true
cfg.Enabled = *req.Keep
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
s.internal(w, r, "save beacon settings", err)
return
}
s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
}
+132
View File
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"testing"
)
// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent besked: %d", resp.StatusCode)
}
if notice["pending"] != true {
t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
}
if notice["enabled"] != true {
t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
}
// De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
settings := beaconSettingsOf(t, ts)
if notice["instance_id"] != settings["instance_id"] {
t.Errorf("id i beskeden = %v, i indstillingerne = %v",
notice["instance_id"], settings["instance_id"])
}
if notice["collector_url"] != settings["collector_url"] {
t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
notice["collector_url"], settings["collector_url"])
}
if notice["version"] == "" || notice["version"] == nil {
t.Error("beskeden siger ikke, hvilken version der bliver sendt")
}
}
// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
// ved næste login.
func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
if resp.StatusCode != http.StatusOK || out["enabled"] != true {
t.Fatalf("behold: %d %v", resp.StatusCode, out)
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
t.Errorf("beskeden kommer igen efter et svar: %v", notice)
}
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
}
}
// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
if resp.StatusCode != http.StatusOK || out["enabled"] != false {
t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
}
s := beaconSettingsOf(t, ts)
if s["enabled"] != false {
t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
t.Errorf("beskeden kommer igen efter et nej: %v", notice)
}
}
// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
}
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
t.Errorf("en tom krop talte som et svar: %v", notice)
}
}
// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
// svares der ingenting frem for et lille tal, ingen kan tolke.
func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
"is_collector": true, "publish_count": true,
}); resp.StatusCode != http.StatusOK {
t.Fatal("bliv collector")
}
// Instansen tæller sig selv, så der er én. Gulvet er højere.
resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("tælling: %d", resp.StatusCode)
}
if out["installs"] != nil {
t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
}
// Sættes gulvet ned, står tallet der.
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
t.Fatal("sænk gulvet")
}
if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
}
}
func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
}
return out
}
+263 -3
View File
@@ -3,10 +3,17 @@ package httpapi
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/gcal"
"github.com/kristianwind/verdande/internal/ics"
"github.com/kristianwind/verdande/internal/safedial"
"github.com/kristianwind/verdande/internal/store"
)
@@ -205,9 +212,21 @@ type calendarStatusJSON struct {
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
// an interface that quietly looks editable is one somebody will try to edit.
ReadOnly bool `json:"read_only"`
// Abonnementer står ved siden af Google-kontoen og ikke inde i den: man kan
// have det ene uden det andet, og det er hele pointen med dem — en privat
// konto, der ikke kan bruge en Internal OAuth-klient, kan abonnere.
Subscriptions []store.CalendarAccount `json:"subscriptions"`
}
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
// Læst først, så de også kommer med, når der ingen Google-konto er.
subscriptions, err := s.db.Subscriptions(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "subscriptions", err)
return
}
user := userFrom(r.Context())
out := calendarStatusJSON{
@@ -223,6 +242,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
return
}
if account == nil || account.RefreshToken == "" {
out.Subscriptions = subscriptions
writeJSON(w, http.StatusOK, out)
return
}
@@ -253,6 +273,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
if calendars != nil {
out.Calendars = calendars
}
out.Subscriptions = subscriptions
writeJSON(w, http.StatusOK, out)
}
@@ -383,12 +404,23 @@ func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
// Returns how many events were written. Also used by the background job, which is
// why it takes a context and a user rather than a request.
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
// Abonnementer først, og deres fejl tæller ikke mod Google.
//
// De to har intet med hinanden at gøre ud over at ende samme sted: et
// abonnement er en adresse, der hentes, hvor Google er et token, der udløber.
// En kalender, hvis vært er nede, må ikke få det til at se ud som om
// Google-forbindelsen er væk — og omvendt.
written, err := s.syncSubscriptions(ctx, user)
if err != nil {
s.log.Warn("calendar subscriptions", "err", err, "user", user.ID)
}
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
if err != nil {
return 0, err
return written, err
}
if account == nil || account.RefreshToken == "" {
return 0, nil // not connected; nothing to do and nothing to complain about
return written, nil // not connected; nothing to do and nothing to complain about
}
// The same guard the mailboxes have, for the same reason: a press of "fetch
@@ -414,7 +446,6 @@ func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, erro
}
from, to := calendarWindow(time.Now())
written := 0
for _, calendar := range calendars {
if !calendar.Shown {
continue
@@ -546,3 +577,232 @@ func validDay(s string) bool {
_, err := time.Parse(calendarDateLayout, s)
return err == nil
}
// --- abonnementer -----------------------------------------------------------------
// maxICSBytes bounds one fetch. A published calendar of a decade of holidays is a
// few hundred kilobytes; ten megabytes is not a calendar, and reading it into
// memory on a schedule with nobody watching is how a homelab instance falls over
// at three in the morning.
const maxICSBytes = 10 << 20
// normaliseCalendarURL turns what somebody pastes into something that can be
// fetched.
//
// `webcal://` is not a scheme anything speaks. It is a convention — the same URL
// over https, with a scheme that makes a desktop hand it to the calendar app
// instead of the browser — and every publisher writes their links that way. A
// person pasting one has pasted the right address; refusing it would be refusing
// them on a technicality.
func normaliseCalendarURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if after, ok := strings.CutPrefix(raw, "webcal://"); ok {
raw = "https://" + after
}
if !strings.HasPrefix(raw, "https://") && !strings.HasPrefix(raw, "http://") {
return "", errors.New("must be an https:// or webcal:// address")
}
// The same wall the AI provider's base_url goes through, and for the same
// reason: this is an address a user typed, and the server is about to fetch it
// on a schedule of their choosing.
if reason := safedial.CheckURL(raw); reason != "" {
return "", errors.New(reason)
}
return raw, nil
}
// icsClient is the client a subscribed calendar is fetched with.
//
// safedial, checked on the *resolved* address at connect time and on every
// redirect hop — because a name that passed the check when it was pasted can
// answer 127.0.0.1 when the sweep runs at three in the morning.
//
// The field exists so a test can put an ordinary client here: httptest listens on
// loopback, which is exactly what this refuses, so a test of the *format* cannot
// otherwise reach its own server. It is never set outside a test, and the wall it
// bypasses is tested where it belongs — `normaliseCalendarURL` refuses a private
// address where it is written down, and `internal/push` proves the connect-time
// half.
func (s *Server) icsClient() *http.Client {
if s.icsFetch != nil {
return s.icsFetch
}
return safedial.Client(30 * time.Second)
}
// fetchICS reads a subscribed calendar.
//
// safedial, not a plain client — checked on the resolved address at connect time
// and on every redirect hop, because a name that passed the check when it was
// pasted can answer 127.0.0.1 when the sweep runs at three in the morning.
func (s *Server) fetchICS(ctx context.Context, url string) (ics.Subscribed, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ics.Subscribed{}, err
}
req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.5")
req.Header.Set("User-Agent", "verdande/"+Version)
resp, err := s.icsClient().Do(req)
if err != nil {
return ics.Subscribed{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return ics.Subscribed{}, fmt.Errorf("%s said %s", resp.Request.URL.Host, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1))
if err != nil {
return ics.Subscribed{}, err
}
if len(body) > maxICSBytes {
return ics.Subscribed{}, errors.New("that calendar is too large")
}
return ics.ParseCalendar(string(body)), nil
}
// syncSubscriptions refreshes every calendar this person subscribes to by address.
//
// One failing address does not stop the others. A subscription is somebody else's
// server, and the ordinary state of somebody else's server is that it is fine
// until one morning it is not — a holiday feed that 404s must not take the
// football club's fixtures down with it.
func (s *Server) syncSubscriptions(ctx context.Context, user *store.User) (int, error) {
subs, err := s.db.Subscriptions(ctx, user.ID)
if err != nil || len(subs) == 0 {
return 0, err
}
from, to := calendarWindow(time.Now())
written := 0
var failures []string
for _, sub := range subs {
if ctx.Err() != nil {
return written, ctx.Err()
}
unlock := s.lockSync("calendar:" + sub.ID)
calendarID, err := s.db.SubscribedCalendarID(ctx, sub.ID)
if err != nil {
unlock()
failures = append(failures, sub.URL)
continue
}
cal, err := s.fetchICS(ctx, sub.URL)
if err != nil {
unlock()
s.log.Warn("subscribed calendar", "err", err, "url", sub.URL, "user", user.ID)
failures = append(failures, sub.URL)
continue
}
// The name the file calls itself, once. A subscription is added under its
// address because that is all there is to go on before it has been fetched.
if err := s.db.RenameSubscribedCalendar(ctx, sub.ID, cal.Name); err != nil {
s.log.Warn("name subscribed calendar", "err", err)
}
rows := make([]store.CalendarEvent, 0, len(cal.Events))
for _, e := range cal.Events {
// Outside the window is not stored. The grid asks about a window, the
// sweep replaces a window, and a decade of holidays in one file would
// otherwise be a decade of rows rewritten every fifteen minutes.
if e.EndDay < from || e.StartDay > to {
continue
}
rows = append(rows, store.CalendarEvent{
RemoteID: e.UID, Summary: e.Summary, Location: e.Location, URL: e.URL,
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
StartDay: e.StartDay, EndDay: e.EndDay, AllDay: e.AllDay,
})
}
if err := s.db.ReplaceCalendarEvents(ctx, calendarID, from, to, rows); err != nil {
unlock()
return written, err
}
if err := s.db.MarkCalendarSynced(ctx, sub.ID, time.Now()); err != nil {
s.log.Warn("mark subscription synced", "err", err)
}
written += len(rows)
unlock()
}
if len(failures) == len(subs) {
return written, fmt.Errorf("none of the %d subscribed calendars could be read", len(subs))
}
return written, nil
}
// handleSubscribeCalendar adds a calendar somebody only has an address to.
//
// Fetched once, here, before it is stored. An address that cannot be read is a
// typo far more often than it is a server that happens to be down, and telling
// somebody now — while they are looking at the field they just pasted into — is
// worth the two seconds. A subscription that silently never fills is the same
// silence the notifications had.
func (s *Server) handleSubscribeCalendar(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
Name string `json:"name"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
url, err := normaliseCalendarURL(req.URL)
if err != nil {
writeFieldErrors(w, map[string]string{"url": err.Error()})
return
}
user := userFrom(r.Context())
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
cal, err := s.fetchICS(ctx, url)
if err != nil {
s.log.Warn("subscribe calendar", "err", err, "user", user.ID)
writeFieldErrors(w, map[string]string{"url": "that address could not be read"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = strings.TrimSpace(cal.Name)
}
if name == "" {
name = url
}
account, err := s.db.SubscribeCalendar(r.Context(), user.ID, url, name)
if err != nil {
// The unique index is the one refusal worth naming: subscribing twice to
// the same address is the same calendar fetched twice, with no way to say
// which one counts.
if errors.Is(err, store.ErrDuplicate) {
writeFieldErrors(w, map[string]string{"url": "that calendar is already subscribed"})
return
}
s.storeError(w, r, "subscribe calendar", err)
return
}
// The events it already fetched, rather than making the person wait fifteen
// minutes for the sweep to prove it worked.
if _, err := s.syncSubscriptions(r.Context(), user); err != nil {
s.log.Warn("first subscription sync", "err", err)
}
writeJSON(w, http.StatusCreated, account)
}
func (s *Server) handleUnsubscribeCalendar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, chi.URLParam(r, "id")); err != nil {
s.storeError(w, r, "unsubscribe calendar", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+35
View File
@@ -3,6 +3,8 @@ package httpapi
import (
"strings"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// Politikken skal stå i svaret, og den skal stadig være stram.
@@ -50,6 +52,39 @@ func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
}
}
// HSTS følger med, når instansen kører over HTTPS — og kun der.
//
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
func TestHSTSTracksHTTPS(t *testing.T) {
overHTTP := newTestServer(t) // BaseURL er http://localhost
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
t.Errorf("HSTS sat over ren HTTP: %q", got)
}
overHTTPS := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://todo.example.dk"
})
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
t.Error("ingen HSTS over HTTPS")
} else if !strings.Contains(got, "includeSubDomains") {
t.Errorf("HSTS uden includeSubDomains: %q", got)
}
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
// mikrofon og placering, som appen aldrig beder om.
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
}
// COOP isolerer browserkonteksten og står på hvert svar.
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
}
}
// Et bilag må ikke kunne blive læst som et dokument.
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
+70 -8
View File
@@ -251,10 +251,12 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
// takes a context and a user rather than a request.
// maxGmailSeen bounds the list of message ids already turned into tasks.
//
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
// asking for less is what makes the consent screen something a person can agree
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
// record has to live on this side, and a record on this side has to be bounded.
// The star is taken off an imported message now, so on a starred trigger the mail
// leaves the query on its own and this list is a safety net rather than the only
// record. It still earns its place: it covers the window between making the task
// and the star coming off, and it is the whole of the dedup for a label trigger,
// where the label is the person's and not ours to remove. A record on this side
// has to be bounded.
//
// A count rather than the previous rule of "whatever Gmail returned this run",
// which was not a bound but a way of forgetting.
@@ -330,7 +332,16 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
projectID = inbox
}
created := 0
// Hentet først, skrevet bagefter. To gennemløb frem for ét, fordi modellen
// skal se hele bunken på én gang: ét spørgsmål for femogtyve mails frem for
// femogtyve spørgsmål er den samme oplysning til en femogtyvendedel af
// ventetiden — og et gennemløb, der tager fem minutter, overlapper med det
// næste.
type fetched struct {
id string
msg gmail.Message
}
var pending []fetched
for _, id := range ids {
if seen[id] {
continue
@@ -338,13 +349,27 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// 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()
return 0, ctx.Err()
}
msg, err := client.Get(ctx, id)
if err != nil {
s.log.Warn("gmail get message", "err", err, "id", id)
continue
}
pending = append(pending, fetched{id: id, msg: msg})
}
forAI := make([]mailForAI, 0, len(pending))
for _, f := range pending {
forAI = append(forAI, mailForAI{
From: gmail.SenderName(f.msg.From), Subject: f.msg.Subject, Snippet: f.msg.Snippet,
})
}
written := s.mailTaskLines(ctx, user, forAI)
created := 0
for i, f := range pending {
id, msg := f.id, f.msg
subject := strings.TrimSpace(msg.Subject)
if subject == "" {
@@ -360,8 +385,22 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
Description: msg.Snippet + "\n\n" + msg.Link,
Priority: 4,
CreatedBy: user.ID,
// The thread, not the message: a reply arriving later is the same
// conversation, and a second task for it would be the duplicate this
// key exists to prevent.
SourceKey: "gmail:thread:" + msg.ThreadID,
}
// Og modellens linje, hvis den skrev en til netop den her mail.
s.applyMailLine(ctx, user, task, written[i], sender+": "+subject)
if err := s.db.CreateTask(ctx, task, nil); err != nil {
if errors.Is(err, store.ErrDuplicate) {
// Somebody got there first — an earlier sweep, or another
// assistant reading the same mailbox. Mark it seen and move on;
// it is handled, which is all this loop wanted.
seen[id] = true
continue
}
s.log.Warn("gmail create task", "err", err)
continue
}
@@ -370,6 +409,29 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
}
// The star comes off every message this query returned that now has a task —
// the ones made this run and the ones a run before it already made. Gated to the
// triggers that are about the star: for a label trigger the star is the person's
// own and not ours to touch, and dedup there rests on the seen list alone.
//
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
// that would not come off. The likeliest reason it will not, right after this
// ships, is a connection still holding the old read-only grant — the write is a
// 403 until the account is reconnected, and this logs it rather than failing the
// sync. It self-heals: the mail stays starred, stays in the next query, and is
// tried again once the grant is there.
if box.Trigger == "starred" || box.Trigger == "both" {
unstar := make([]string, 0, len(ids))
for _, id := range ids {
if seen[id] {
unstar = append(unstar, id)
}
}
if err := client.Unstar(ctx, unstar); err != nil {
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
}
}
if created > 0 {
// The ids this run saw, on top of the ones already known.
//
@@ -431,8 +493,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
//
// 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
// Google issues no Gmail access to an unregistered client, and `gmail.modify` 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.
+82 -1
View File
@@ -1,14 +1,17 @@
package httpapi
import (
"github.com/kristianwind/verdande/internal/store"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// A Gmail failure has to survive being reported.
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
// A message that becomes a task has its star taken off — the write that the whole
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
// the way the IMAP side unflags what it imports.
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
var unstarred struct {
hit bool
body struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}
}
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
unstarred.hit = true
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &unstarred.body)
w.WriteHeader(http.StatusNoContent)
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
writeTestJSON(w, map[string]any{
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
"payload": map[string]any{"headers": []map[string]string{
{"name": "From", "value": "Anders <anders@example.dk>"},
{"name": "Subject", "value": "Møde på fredag"},
}},
})
case strings.HasSuffix(r.URL.Path, "/messages"):
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
default:
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
}
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GoogleTokenURL = 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)
}
// A live access token, so the run goes straight to the list without 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)
}
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
}
if created, _ := body["created"].(float64); created != 1 {
t.Fatalf("created = %v, want 1", body["created"])
}
if !unstarred.hit {
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
}
if strings.Join(unstarred.body.IDs, ",") != "m1" {
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
}
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
}
}
func writeTestJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
+194
View File
@@ -0,0 +1,194 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// doRaw sender en krop, der ikke er JSON — en genvej på en telefon sender ren
// tekst, og en webhook-formular sender felter. Det er halvdelen af det, der
// prøves her.
func (ts *testServer) doRaw(t *testing.T, method, path, contentType, body string) (*http.Response, map[string]any) {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", contentType)
resp, err := ts.client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
// hookURL henter personens egen krog-adresse, som fladen viser den.
func (ts *testServer) hookURL(t *testing.T) string {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/hook-url", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent krog-URL: %d", resp.StatusCode)
}
url, _ := out["url"].(string)
if !strings.Contains(url, "/inbound/hook/") {
t.Fatalf("url = %q", url)
}
return url
}
// hookPath er stien, testserveren kan kaldes på — URL'en er absolut, fordi den
// skal kunne kopieres ind i en genvej på en telefon.
func hookPath(url string) string {
return url[strings.Index(url, "/inbound/hook/"):]
}
// En linje ren tekst, skubbet ind udefra, bliver til en opgave i indbakken — og
// betyder det samme, som den ville betyde i feltet øverst i fladen.
func TestAPushFromAnotherAppLandsInTheInbox(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
resp, out := anon.doRaw(t, "POST", path, "text/plain",
"Ring til Anders p1\nhan ringede i går")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("skub: %d, %v", resp.StatusCode, out)
}
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
tasks, _ := list["tasks"].([]any)
var found map[string]any
for _, raw := range tasks {
task := raw.(map[string]any)
if task["content"] == "Ring til Anders" {
found = task
}
}
if found == nil {
t.Fatalf("opgaven kom ikke i indbakken: %v", tasks)
}
// Parseren er den samme som fladens: p1 er en prioritet og ikke en del af
// teksten, og resten af kroppen står under opgaven.
if found["priority"] != float64(1) {
t.Errorf("prioritet = %v, want 1", found["priority"])
}
if found["description"] != "han ringede i går" {
t.Errorf("beskrivelse = %v", found["description"])
}
}
// JSON fra en tjeneste, med de feltnavne folk faktisk sender.
func TestTheHookReadsJSON(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
for _, body := range []string{
`{"text": "Køb mælk"}`,
`{"title": "Køb mælk"}`,
`{"content": "Køb mælk"}`,
} {
resp, out := anon.doRaw(t, "POST", path, "application/json", body)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("%s: %d, %v", body, resp.StatusCode, out)
}
if out["content"] != "Køb mælk" {
t.Errorf("%s gav %v", body, out["content"])
}
}
// En krop, der siger JSON og ikke er det, læses ikke som tekst bagefter: en
// opgave, der hedder `{"txt": …}`, er en dårligere fejlmeddelelse end et 422.
resp, _ := anon.doRaw(t, "POST", path, "application/json", `{"txt": "skrevet forkert"}`)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("forkert JSON gav %d, want 422", resp.StatusCode)
}
}
// Et token, der ikke findes, er det samme svar som et, der aldrig har eksisteret.
// Alt andet er en maskine til at gætte tokens med.
func TestAnUnknownHookTokenIsNotFound(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anon := newSignedOut(t, ts)
resp, _ := anon.doRaw(t, "POST", "/inbound/hook/deteringentingogsaaslet", "text/plain", "hej")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("ukendt token gav %d, want 404", resp.StatusCode)
}
}
// Den gamle URL holder op med at virke, når man skifter den. Det er den eneste vej
// tilbage: adressen står i en genvej på en telefon eller i et script.
func TestRotatingTheHookRetiresTheOldURL(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
old := hookPath(ts.hookURL(t))
resp, out := ts.do(t, "POST", "/api/v1/hook-url/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("skift: %d", resp.StatusCode)
}
fresh := hookPath(out["url"].(string))
if fresh == old {
t.Fatal("den nye URL er den gamle")
}
anon := newSignedOut(t, ts)
if resp, _ := anon.doRaw(t, "POST", old, "text/plain", "gammel"); resp.StatusCode != http.StatusNotFound {
t.Errorf("den gamle URL virker stadig: %d", resp.StatusCode)
}
if resp, _ := anon.doRaw(t, "POST", fresh, "text/plain", "ny"); resp.StatusCode != http.StatusCreated {
t.Errorf("den nye URL virker ikke: %d", resp.StatusCode)
}
}
// Ingen tekst er ikke en opgave. En tom opgave i indbakken er værre end en fejl,
// fordi den ser ud som om integrationen virker.
func TestAnEmptyPushIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
resp, _ := anon.doRaw(t, "POST", path, "text/plain", " \n ")
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("tom tekst gav %d, want 422", resp.StatusCode)
}
}
// Et token kan én ting. Det ligger i et script på en anden maskine, og hvad der
// ellers kan nås med det, er hvad der er sluppet ud sammen med det.
func TestTheHookTokenIsNotAnAPIToken(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
url := ts.hookURL(t)
token := url[strings.LastIndex(url, "/")+1:]
anon := newSignedOut(t, ts)
req, err := http.NewRequest("GET", anon.URL+"/api/v1/tasks", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := anon.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatal("krog-tokenet gav adgang til API'et")
}
}
+219 -12
View File
@@ -2,7 +2,9 @@ package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
@@ -155,6 +157,191 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
}
// --- en krog ind i indbakken --------------------------------------------------------
type hookURLResponse struct {
URL string `json:"url"`
}
// handleGetHookURL returns the personal address another program can push a task
// to, minting the token on first ask.
//
// Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
// jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
// en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
// mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter
// — fra en genvej på telefonen, et script, en anden tjeneste.
func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "hook token", err)
return
}
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
}
// handleRotateHookURL mints a new one and retires the old.
//
// Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en
// telefon eller i et script på en anden maskine, og der er ikke andet at trække
// tilbage end tokenet selv.
func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, r, "generate hook token", err)
return
}
if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
s.internal(w, r, "set hook token", err)
return
}
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
}
func (s *Server) hookURL(token string) string {
return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token
}
type hookRequest struct {
// Text er linjen, der bliver til opgaven. `title` og `content` tages med som
// de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en
// integration, der fejler på et feltnavn, fejler tavst hos den, der satte den
// op.
Text string `json:"text"`
Title string `json:"title"`
Content string `json:"content"`
// Note er det, der står under opgaven. `body` og `description` ligeså.
Note string `json:"note"`
Body string `json:"body"`
Description string `json:"description"`
}
func (h hookRequest) line() string {
for _, v := range []string{h.Text, h.Title, h.Content} {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func (h hookRequest) note() string {
for _, v := range []string{h.Note, h.Body, h.Description} {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// handleInboundHook turns a POST from somewhere else into a task in the inbox.
//
// Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående
// post: den, der kalder, er et script eller en genvej på en telefon, og hverken
// kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i
// den ene persons indbakke — hvor et API-token kan alt.
//
// Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
// tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej,
// der bare sender det, man har markeret. Første linje bliver til opgaven, resten
// til noten under den — det er sådan, en tekst, man har delt, ser ud.
//
// Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma"
// betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at
// have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder.
func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) {
user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token"))
if err != nil {
// Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der
// kender forskel, er en maskine til at gætte tokens med.
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
return
}
line, note := s.readHookBody(r)
if strings.TrimSpace(line) == "" {
writeFieldErrors(w, map[string]string{"text": "required"})
return
}
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
content := parsed.Content
if strings.TrimSpace(content) == "" {
content = line
}
projectID := ""
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
}
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, r, "inbox", err)
return
}
}
task := &store.Task{
ProjectID: projectID, Content: content, Priority: parsed.Priority,
Description: strings.TrimSpace(note), DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, r, "create task from hook", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
s.log.Info("task created from hook", "user", user.ID)
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content})
}
// readHookBody er de tre måder, en linje tekst kommer ind ad.
//
// Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret
// Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en
// genvej på en telefon sender, når den ikke er blevet bedt om andet.
//
// En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den,
// der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder
// `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422.
func (s *Server) readHookBody(r *http.Request) (line, note string) {
const maxBody = 64 << 10
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
if err != nil {
return "", ""
}
kind := r.Header.Get("Content-Type")
switch {
case strings.Contains(kind, "application/json"):
var req hookRequest
if err := json.Unmarshal(body, &req); err != nil {
return "", ""
}
return req.line(), req.note()
case strings.Contains(kind, "application/x-www-form-urlencoded"):
values, err := url.ParseQuery(string(body))
if err != nil {
return "", ""
}
req := hookRequest{
Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"),
Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"),
}
return req.line(), req.note()
}
// Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst,
// man har delt fra en anden app, ud — en overskrift og noget under.
text := strings.TrimSpace(string(body))
first, rest, _ := strings.Cut(text, "\n")
return first, strings.TrimSpace(rest)
}
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
func mailToken(address string) string {
if i := strings.LastIndex(address, "<"); i >= 0 {
@@ -215,20 +402,31 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
return
}
// The address is checked where it is typed, as well as when it is dialled.
// The address is checked where it is typed, as well as when it is dialled. A
// scheme other than http or https is refused outright, for everybody —
// file:// and gopher:// are not providers.
//
// safedial refuses a private destination at connect time and is the actual
// wall; this is so the person entering it hears about it now rather than
// discovering it later as a failed summary. A scheme other than http or https
// is refused outright — file:// and gopher:// are not providers.
// The private-network wall has one exception, and it is the point of the
// provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model
// on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150.
// safedial refuses exactly those, so the address the field suggested in its
// own placeholder was an address the server would not accept.
//
// Admins only, because the wall is there for a real reason: a server that
// fetches a user-supplied address lets the person supplying it reach what
// they cannot reach themselves. An admin on a self-hosted instance already
// owns the host and everything on its network, so this grants them nothing
// new. An invited user is a different person, and for them it stays shut.
if url := strings.TrimSpace(req.BaseURL); url != "" {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
return
}
if reason := safedial.CheckURL(url); reason != "" {
writeFieldErrors(w, map[string]string{"base_url": reason})
return
if !user.IsAdmin {
if reason := safedial.CheckURL(url); reason != "" {
writeFieldErrors(w, map[string]string{"base_url": reason})
return
}
}
req.BaseURL = url
}
@@ -265,11 +463,20 @@ func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
v, _ := values[key].(string)
return v
}
// Worked out per request rather than stored beside the address: it answers
// "may this person reach the private network", and somebody who is no longer
// an admin should stop being able to. A flag written at save time would
// outlive the role that granted it.
allowPrivate := false
if u := userFrom(r.Context()); u != nil && u.IsAdmin {
allowPrivate = true
}
return ai.Config{
Provider: ai.Provider(str("provider")),
APIKey: str("api_key"),
BaseURL: str("base_url"),
Model: str("model"),
Provider: ai.Provider(str("provider")),
APIKey: str("api_key"),
BaseURL: str("base_url"),
Model: str("model"),
AllowPrivate: allowPrivate,
}, nil
}
+254
View File
@@ -2,13 +2,18 @@ package httpapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// --- MCP -----------------------------------------------------------------------------
@@ -714,6 +719,86 @@ func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
}
}
// Reported from use as "the AI settings are not saved" — the second time, and a
// different cause than the one above.
//
// The provider is called "OpenAI-kompatibel (Ollama, LM Studio, …)" and the
// address field suggests http://localhost:11434/v1. Both of those live on the
// private network, which safedial refuses — so the address the interface proposed
// was the address the server rejected, and the one provider that exists for
// running your own model could not be configured at all.
//
// It looked like a save that did nothing, because the 422 came back on a field
// with nowhere to show it. Two bugs wearing one coat.
func TestAdminMayPointAIAtAModelOnTheLAN(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// The three shapes a self-hosted model actually takes: another machine on the
// LAN, the loopback name, and the loopback address.
for _, addr := range []string{
"http://192.168.1.150:8080/v1",
"http://localhost:11434/v1",
"http://127.0.0.1:1234/v1",
} {
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "model": "gemma-4-26b-qat", "base_url": addr,
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("save %s: status %d, body %v", addr, resp.StatusCode, body)
}
}
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
if body["base_url"] != "http://127.0.0.1:1234/v1" {
t.Errorf("base_url = %v after saving, want it to have survived", body["base_url"])
}
if body["provider"] != "compatible" {
t.Errorf("provider = %v", body["provider"])
}
}
// The wall stays up for everybody else.
//
// An admin on a self-hosted instance already owns the host and its network, so a
// private destination grants them nothing they did not have. An invited user is a
// different person, and for them the address field would be a way to find out
// what answers on the host's network.
func TestAnInvitedUserCannotPointAIAtThePrivateNetwork(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "andreas@example.dk", "Andreas")
resp, body := other.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "model": "llama3.1",
"base_url": "http://192.168.1.150:8080/v1",
})
if resp.StatusCode == http.StatusNoContent {
t.Fatal("an invited user was allowed to point the AI at the private network")
}
fields, _ := body["fields"].(map[string]any)
if fields["base_url"] == nil {
t.Errorf("the refusal did not name the field it was about: %v", body)
}
}
// A scheme that is not http or https is refused for everybody, admin or not.
func TestAISettingsRefuseANonHTTPAddress(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "model": "llama3.1", "base_url": "file:///etc/passwd",
})
if resp.StatusCode == http.StatusNoContent {
t.Fatal("file:// was accepted as a provider address")
}
fields, _ := body["fields"].(map[string]any)
if fields["base_url"] == nil {
t.Errorf("no error on base_url: %v", body)
}
}
func TestGmailSettings(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
@@ -1468,3 +1553,172 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
t.Error("the second rotation returned the same address")
}
}
// "Der kommer ingen notifikationer" må kunne besvares.
//
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
"title": "Prøve", "body": "kom den frem?",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if body["subscriptions"] != float64(0) {
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
}
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
// som et navngivent afslag og ikke som tavshed.
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
UserID: user.ID,
Endpoint: "https://push.example.invalid/abc",
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
}); err != nil {
t.Fatal(err)
}
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
if body["subscriptions"] != float64(1) {
t.Fatalf("subscriptions = %v", body["subscriptions"])
}
failed, _ := body["failed"].([]any)
if len(failed) != 1 {
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
}
first, _ := failed[0].(map[string]any)
if first["service"] != "push.example.invalid" {
t.Errorf("service = %v", first["service"])
}
if first["reason"] == "" || first["reason"] == nil {
t.Error("afslaget kom uden en grund")
}
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
if strings.Contains(fmt.Sprint(first), "/abc") {
t.Errorf("endepunktet lækkede med ud: %v", first)
}
}
// Et abonnement på en kalender, man kun har en adresse til.
//
// Bedt om som "abonnering af flere kalendere (ics og webcal?)". Google-vejen er
// OAuth og én konto pr. person; det her er en adresse, og man har flere. Det er
// også den eneste vej for en privat konto, som en Internal-registrering afviser
// med `org_internal`.
//
// Prøven går uden om handleren og ind i det, den kalder. Grunden er væggen:
// `httptest` lytter på loopback, og safedial nægter — både når adressen skrives
// ned og igen når den hentes. Det er rigtigt, og det skal ikke kunne slås fra fra
// en prøve. Så adressereglerne prøves for sig i TestWebcalIsRewrittenToHTTPS, og
// her prøves resten af vejen: hentning, format, navn, gitteret og afmeldingen.
func TestSubscribingToACalendarByAddress(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
day := time.Now().AddDate(0, 0, 2).Format("20060102")
feedBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Helligdage\r\n" +
"BEGIN:VEVENT\r\nUID:en@example.dk\r\n" +
"DTSTART;VALUE=DATE:" + day + "\r\nDTEND;VALUE=DATE:" + day + "\r\n" +
"SUMMARY:Grundlovsdag\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
var hits int
feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.Header().Set("Content-Type", "text/calendar")
w.Write([]byte(feedBody))
}))
defer feed.Close()
ts.api.icsFetch = &http.Client{Timeout: 10 * time.Second}
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
// Tilføjet under sin adresse, som er alt der er at gå efter, før den er hentet.
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, feed.URL); err != nil {
t.Fatal(err)
}
if _, err := ts.api.syncSubscriptions(t.Context(), user); err != nil {
t.Fatal(err)
}
if hits == 0 {
t.Fatal("adressen blev aldrig hentet")
}
// Navnet kommer fra filen. X-WR-CALNAME er det, alle udgivere bruger; der
// findes ingen standardegenskab for det.
_, status := ts.do(t, "GET", "/api/v1/calendar", nil)
subs, _ := status["subscriptions"].([]any)
if len(subs) != 1 {
t.Fatalf("%d abonnementer i svaret", len(subs))
}
first, _ := subs[0].(map[string]any)
if first["account"] != "Helligdage" {
t.Errorf("navnet er %v, want Helligdage", first["account"])
}
// Og begivenheden står i gitteret.
from := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
to := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
_, events := ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
list, _ := events["events"].([]any)
if len(list) != 1 {
t.Fatalf("%d begivenheder efter abonnementet", len(list))
}
if e, _ := list[0].(map[string]any); e["summary"] != "Grundlovsdag" {
t.Errorf("begivenheden er %v", e["summary"])
}
// Den samme adresse to gange er den samme kalender hentet to gange, og der er
// ingen måde at sige hvilken der gælder.
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, ""); !errors.Is(err, store.ErrDuplicate) {
t.Errorf("den samme adresse blev accepteret to gange: %v", err)
}
// Fjernet igen, og begivenhederne går med.
resp, _ := ts.do(t, "DELETE", "/api/v1/calendar/subscriptions/"+first["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("unsubscribe: status %d", resp.StatusCode)
}
_, events = ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
if list, _ := events["events"].([]any); len(list) != 0 {
t.Errorf("%d begivenheder blev tilbage efter afmeldingen", len(list))
}
}
// webcal:// er https med et andet skema foran. Det er ikke en protokol, noget
// taler — det er en konvention, der får et skrivebord til at give adressen til
// kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres
// links sådan. At afvise den ville være at afvise folk på en teknikalitet.
func TestWebcalIsRewrittenToHTTPS(t *testing.T) {
got, err := normaliseCalendarURL("webcal://example.dk/kalender.ics")
if err != nil {
t.Fatal(err)
}
if got != "https://example.dk/kalender.ics" {
t.Errorf("blev til %q", got)
}
// Og en adresse indad afvises, hvor den skrives — ikke først når den hentes.
if _, err := normaliseCalendarURL("http://127.0.0.1:8080/x.ics"); err == nil {
t.Error("en loopback-adresse blev godtaget")
}
if _, err := normaliseCalendarURL("file:///etc/passwd"); err == nil {
t.Error("et skema, der ikke er http, blev godtaget")
}
}
+27 -1
View File
@@ -173,13 +173,22 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
return 0, err
}
// Ét spørgsmål til modellen for hele gennemløbet, inden der bliver skrevet
// noget. Er der ingen model, er kortet tomt, og hver opgave hedder afsender og
// emne, som den altid har gjort.
forAI := make([]mailForAI, 0, len(messages))
for _, msg := range messages {
forAI = append(forAI, mailForAI{From: msg.From, Subject: msg.Subject, Snippet: msg.Snippet})
}
written := s.mailTaskLines(ctx, user, forAI)
created := 0
highest := m.LastUID
// The uids of the mails that actually became tasks. Only those get unflagged,
// and only after the task is committed: a flag removed from a mail whose task
// was never written is a mail nobody will ever see again.
done := make([]uint32, 0, len(messages))
for _, msg := range messages {
for i, 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
@@ -202,8 +211,25 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
Description: msg.Snippet,
Priority: 4,
CreatedBy: user.ID,
// Host and uid together: a uid is only unique within one mailbox on
// one server, and two accounts will hand out the same small numbers.
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
}
// Og modellens linje, hvis den skrev en til netop den her. Emnet bliver
// stående i beskrivelsen: har den misforstået mailen, skal det oprindelige
// kunne læses ved siden af.
s.applyMailLine(ctx, user, task, written[i], content)
if err := s.db.CreateTask(ctx, task, nil); err != nil {
if errors.Is(err, store.ErrDuplicate) {
// Already a task. Still count it as read, or last_uid never moves
// past it and every sweep from here on stops at the same message.
done = append(done, msg.UID)
if msg.UID > highest {
highest = msg.UID
}
continue
}
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
continue
}
+3 -1
View File
@@ -527,7 +527,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
"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 — " +
"links to a note by its title. A third, `@Navn`, shares the note with that " +
"person and tells them — so only write one when the note is meant for them. " +
"Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
+248 -18
View File
@@ -19,21 +19,38 @@ import (
// 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.
// Access has three ways in, and a note may have any of them at once. A note with
// no project is its author's alone — the same answer a task in the inbox gets. A
// note in a project is readable by everyone who can read the project and writable
// by an editor. And a note can be shared with a person directly, which grants that
// person a role of its own. The effective role is the most any of the three allow.
// 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.
// mayTouchNote reports whether this person may act on the note at the given role —
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
// on a shared project or a shared note, may 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())
// The owner and the project path, exactly as before.
if n.ProjectID == "" {
return n.CreatedBy == user.ID
if n.CreatedBy == user.ID {
return true
}
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
return true
}
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
return err == nil
// A direct share grants its role on top of whatever the above allow.
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
return shareRole.AtLeast(role)
}
return false
}
// ownsNote reports whether this person is the note's owner — its created_by — which
// is who, and only who, may hand it to other people. An editor on a shared note can
// change its text; giving away access is the owner's alone, the same shape a
// project takes where members edit but only the owner invites.
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
}
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "search notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
return
}
@@ -62,7 +79,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "notes in project", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
return
}
@@ -75,7 +92,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list archived notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
return
}
@@ -87,7 +104,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
}
// briefly replaces each note's body with the first of it.
@@ -187,9 +204,152 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "create note", err)
return
}
// En ny note kan være den, en delt note hele tiden har peget på: linket er
// skrevet som en titel, og titlen fandtes ikke før nu.
//
// Kun når der står noget i den. En tom note har ingen titel, og et link peger på
// en titel — så der er intet at regne, og det kostede alligevel: udregningen
// åbner hver eneste af ejerens noter, og den lå her mellem "Ny note" og det
// svar, fladen venter på for at kunne tegne editoren. Halvfems millisekunder er
// nok til at nå at taste den første linje ind i den forrige note, som så blev
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
if strings.TrimSpace(n.Body) != "" {
s.followLinks(r, n)
s.shareWithMentioned(r, n, "")
}
writeJSON(w, http.StatusCreated, n)
}
// followLinks holder de udledte delinger i takt med teksten.
//
// De noter, en delt note peger på, følger med — og teksten kan ændre sig, efter
// delingen er sat. Et link skrevet i dag i en note, der blev delt i går, skal føre
// samme sted hen for begge.
//
// Kun ejerens egne gemninger regner om, og det er ikke en optimering. Et link
// peger på en titel, og en medredaktør må skrive i teksten — så hvis enhver
// gemning talte med, kunne den, man har delt en note med, skrive
// `[[Bankoplysninger]]` ind i den og dermed dele den med sig selv.
//
// Det lukker ikke hullet helt, og det skal siges frem for at lyde lukket: skriver
// medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note
// med dér. Det, der står tilbage mod det, er synlighed — linket står som tekst i
// ejerens egen note, og delingspanelet viser hver note, der er fulgt med, og hvem
// der har den. En stille eskalering er det ikke; en, man kan overse, er den.
//
// Fejler det, siges det i loggen og ikke til den, der gemte: noten *er* gemt, og
// et svar, der lyder som om den ikke er, ville få dem til at skrive den igen.
func (s *Server) followLinks(r *http.Request, n *store.Note) {
if n == nil || n.CreatedBy != userFrom(r.Context()).ID {
return
}
if _, err := s.db.SyncLinkedShares(r.Context(), n.CreatedBy); err != nil {
s.log.Error("sync linked shares", "err", err, "note", n.ID)
}
}
// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @.
//
// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var
// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den
// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den.
//
// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde
// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville
// hver eneste gemning være en ny deling og en ny besked om det samme navn.
//
// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den
// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele
// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet
// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver
// kun besked til en, der kan se noten i forvejen.
//
// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der
// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet,
// og en rolle, der er hævet dér, sænkes ikke igen af en omtale.
// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også
// fortæller dem, at noten er blevet rettet.
func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool {
told := map[string]bool{}
if n == nil || n.CreatedBy == "" {
return told
}
// Slået op mod alle konti på instansen — det er dem, noten kan deles med, og
// derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke
// deles med den, der har den.
people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy)
if err != nil {
s.log.Warn("people for mentions", "err", err, "note", n.ID)
return told
}
was := map[string]bool{}
for _, id := range store.MentionsIn(before, people) {
was[id] = true
}
fresh := []string{}
for _, id := range store.MentionsIn(n.Body, people) {
if !was[id] {
fresh = append(fresh, id)
}
}
if len(fresh) == 0 {
return told
}
actor := userFrom(r.Context())
owner := actor.ID == n.CreatedBy
shared := false
for _, id := range fresh {
if !owner {
continue
}
// En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der
// ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren
// skrev deres navn i teksten.
if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil {
s.log.Warn("mention share role", "err", err, "note", n.ID)
continue
} else if has {
continue
}
if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil {
s.log.Warn("share with mentioned", "err", err, "note", n.ID)
continue
}
shared = true
}
if shared {
// Og det, noten peger på, følger med delingen — samme vej som en deling
// sat i panelet.
s.followLinks(r, n)
}
// Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har
// givet adgang, er med — og kun til dem, så en medredaktørs omtale af en
// fremmed ikke bliver en besked om en note, de ikke kan åbne.
audience, err := s.db.NoteAudience(r.Context(), n.ID)
if err != nil {
s.log.Warn("note audience for mention", "err", err, "note", n.ID)
return told
}
canSee := map[string]bool{}
for _, id := range audience {
canSee[id] = true
}
for _, id := range fresh {
if !canSee[id] {
continue
}
s.notify(r, &store.Notification{
UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title,
})
told[id] = true
}
return told
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
@@ -206,9 +366,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
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 != nil && *body.ProjectID != n.ProjectID {
// Filing a note is the owner's call, not an editor's. A shared editor may
// change the text; letting them also move the note would let them file
// somebody else's note into a project the owner is not in — and the owner,
// who is not a member there, would lose their own note. So the move is
// owner-only, and then only into a project the owner may write to.
if !s.ownsNote(r, n) {
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
return
}
if *body.ProjectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
@@ -218,6 +385,7 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
before := n.Body
if body.Body != nil {
n.Body = *body.Body
}
@@ -225,20 +393,67 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
n.Pinned = *body.Pinned
}
// Læst før gemningen, sammenlignet efter. En gemning er en tastepause, ikke en
// begivenhed, så de fleste af dem ændrer intet af det her — og udregningen
// åbner hver eneste af ejerens noter, fordi titlerne er forseglet. Den skal
// køre, når svaret kan være blevet et andet, og ellers ikke.
wasLinking := store.LinksIn(before)
wasTitled := n.Title
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.internal(w, r, "update note", err)
return
}
// Titlen tæller med: den *er* første linje, så en omskrevet overskrift flytter
// det, andres links peger på, uden at et eneste link er rørt.
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
s.followLinks(r, n)
}
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
// det ville lære folk at overse dem alle sammen.
if before != n.Body {
// Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og
// ikke også "rettede en note" om den samme gemning — det er én handling, og
// den ene sætning siger mere end den anden.
mentioned := s.shareWithMentioned(r, n, before)
s.notifyNoteChanged(r, n, mentioned)
}
writeJSON(w, http.StatusOK, n)
}
func sameLinks(a, b []store.NoteLink) bool {
if len(a) != len(b) {
return false
}
in := make(map[store.NoteLink]bool, len(a))
for _, l := range a {
in[l] = true
}
for _, l := range b {
if !in[l] {
return false
}
}
return true
}
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) {
// Deleting is not editing. Someone the note was shared with — even as an editor
// — may change its text, but throwing away the owner's note is the owner's to
// do. A project editor keeps the reach they have always had over a project's
// notes; a direct share does not extend that far.
mayDelete := s.ownsNote(r, n)
if !mayDelete && n != nil && n.ProjectID != "" {
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
mayDelete = perr == nil
}
if n == nil || !mayDelete {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
@@ -246,6 +461,9 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "delete note", err)
return
}
// En note i papirkurven er ikke længere et udgangspunkt for noget. Det, den tog
// med sig, skal derfor heller ikke blive stående hos dem, den blev delt med.
s.followLinks(r, n)
w.WriteHeader(http.StatusNoContent)
}
@@ -280,6 +498,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
return
}
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
// the second, so two notes made in the same second have no order between them —
// and then which of two same-titled notes gets the plain name and which gets
// "(2)" is a coin toss that lands differently each export. Ordering the whole
// thing by when it was made settles it: the first note of a name keeps the name.
sort.SliceStable(notes, func(i, j int) bool {
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
return notes[i].ID < notes[j].ID
}
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
})
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
+186
View File
@@ -0,0 +1,186 @@
package httpapi
import (
"net/http"
"strings"
"testing"
)
// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og
// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes.
func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "sofie@example.dk", "role": "editor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body)
}
invited, _ := body["invited"].(map[string]any)
if invited == nil || invited["email"] != "sofie@example.dk" {
t.Fatalf("the response does not say who was invited: %v", body)
}
if invited["role"] != "editor" {
t.Errorf("role = %v, want the one the owner chose", invited["role"])
}
// Uden en postserver er linket det eneste, der findes, og det skal stå i
// svaret frem for at blive lovet i en mail, der ikke kom af sted.
if body["emailed"] != false {
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
}
link, _ := body["link"].(string)
if !strings.Contains(link, "token=") {
t.Fatalf("link = %q, want one carrying a token", link)
}
// Den venter i panelet under noten, så ejeren kan se, at den er sendt.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
if len(pending) != 1 {
t.Fatalf("want one pending invite under the note, got %v", pending)
}
// Og linket laver kontoen og delingen i ét.
token := link[strings.Index(link, "token=")+len("token="):]
fresh := newSignedOut(t, owner)
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Sofie", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
t.Fatalf("signup with the note invite: status %d", resp.StatusCode)
}
// Noten er hendes at læse — og som medredaktør også at rette.
resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode)
}
if note["title"] != "Aftale om levering" {
t.Errorf("the invited person got something else: %v", note)
}
resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]string{"body": "# Aftale om levering\nfredag kl. 10"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode)
}
// Og invitationen er brugt op: den står ikke længere og venter.
_, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
if left, _ := after["invites"].([]any); len(left) != 0 {
t.Errorf("the invite is still pending after it was accepted: %v", left)
}
shares, _ := after["shares"].([]any)
if len(shares) != 1 {
t.Fatalf("want the accepted invite to be a share, got %v", shares)
}
}
// En adresse, der viser sig at høre til en konto, er den konto — ikke en
// invitation til at oprette en, som personen aldrig kan komme igennem.
func TestSharingByEmailFindsAnExistingAccount(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Prisliste\n2026")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "Sofie@Example.dk"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body)
}
person, _ := body["user"].(map[string]any)
if person == nil || person["id"] != sofie {
t.Fatalf("the response does not say who it reached: %v", body)
}
if _, invited := body["invited"]; invited {
t.Error("an address with an account was invited rather than shared with")
}
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || role != "viewer" {
t.Fatalf("share role = %v (%v), want viewer by default", role, err)
}
}
// To links til den samme indbakke er ikke en invitation mere: det er to, der gør
// det samme, og kun det ene kan trækkes tilbage ad gangen.
func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Bankoplysninger\nkonto")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("first invite: status %d", resp.StatusCode)
}
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("second invite: status %d, want 409", resp.StatusCode)
}
// Trukket tilbage kan den derimod, og så kan der sendes en ny.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
id := pending[0].(map[string]any)["id"].(string)
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("withdraw: status %d", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode)
}
}
// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til
// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før.
func TestOnlyTheOwnerCanInviteToANote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "fremmed@example.dk"})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode)
}
if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 {
t.Fatalf("invites = %v (%v), want none", n, err)
}
}
// En invitation til én note kan ikke bruges til at rydde en anden notes
// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom.
func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
mine := owner.createNote(t, "# Min note\n1")
other := owner.createNote(t, "# Min anden note\n2")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatal("invite failed")
}
invites, err := owner.db.ListNoteInvites(t.Context(), mine)
if err != nil || len(invites) != 1 {
t.Fatalf("invites = %v (%v)", invites, err)
}
resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode)
}
if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 {
t.Error("the invite was withdrawn through a note it does not belong to")
}
}
+132
View File
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"testing"
)
// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler
// noten, og den, der bliver nævnt, får besked om det.
func TestMentioningSomebodySharesTheNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Aftale om levering\nikke noget endnu")
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode)
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK {
t.Fatalf("ret noten: %d", resp.StatusCode)
}
// Delt, som læser.
role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || !has || role != "viewer" {
t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err)
}
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode)
}
// Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én
// handling, og den ene sætning siger mere end den anden.
list, unread := notifications(t, other)
if len(list) != 1 || unread != 1 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
}
if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID {
t.Errorf("besked = %v", n)
}
}
// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved
// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder.
func TestTheSameMentionOnlyCountsOnce(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
noteID := owner.createNote(t, "# Aftale\n@Sofie kigger")
// Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen.
if list, _ := notifications(t, other); len(list) != 1 {
t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list))
}
for i := 0; i < 3; i++ {
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
}
// Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre
// omtaler mere.
list, _ := notifications(t, other)
if len(list) != 2 {
t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list))
}
if firstOf(t, list)["kind"] != "note.changed" {
t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list))
}
}
// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den,
// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed.
func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
editor := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
peterClient := owner.newUser(t, "peter@example.dk", "Peter")
peter := userID(t, owner, "peter@example.dk")
noteID := owner.createNote(t, "# Aftale\nintet")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK {
t.Fatal("medredaktøren retter")
}
if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has {
t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err)
}
// Og Peter får ikke besked om en note, han ikke kan åbne.
if list, _ := notifications(t, peterClient); len(list) != 0 {
t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list))
}
// Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse.
if list, _ := notifications(t, owner); len(list) == 0 {
t.Error("ejeren hørte ikke, at medredaktøren skrev i noten")
}
}
// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke
// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn.
func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Aftale\nintet")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || role != "editor" {
t.Errorf("rolle = %v (%v), want editor uændret", role, err)
}
}
+388
View File
@@ -0,0 +1,388 @@
package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// markShared fills in the two fields the list needs to show a note somebody else
// shared with the reader: that it is such a note, and whose it is.
//
// Done in one pass over the page rather than per note: one query asks which of
// these ids are shared with the reader, and one asks for the owners' names and
// colours. A note the reader owns is never "shared with me", however it was
// reached — the group is other people's notes, not one's own.
func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note {
me := userFrom(r.Context()).ID
if len(notes) == 0 {
return notes
}
ids := make([]string, 0, len(notes))
for _, n := range notes {
if n.CreatedBy != me {
ids = append(ids, n.ID)
}
}
shared, err := s.db.NotesSharedWith(r.Context(), me, ids)
if err != nil {
// The list is worth more than the chip: a note without its "shared" mark
// still reads and still opens. Log nothing here — the store already did.
return notes
}
owners := map[string]store.Person{}
if len(shared) > 0 {
ownerIDs := make([]string, 0, len(shared))
for _, n := range notes {
if shared[n.ID] && n.CreatedBy != "" {
ownerIDs = append(ownerIDs, n.CreatedBy)
}
}
owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs)
}
for i := range notes {
if !shared[notes[i].ID] {
continue
}
notes[i].SharedWithMe = true
if p, ok := owners[notes[i].CreatedBy]; ok {
owner := p
notes[i].Owner = &owner
}
}
return notes
}
// handleListNoteShares is who a note is shared with. Owner only: the list of people
// who can see your note is itself something only you should see.
func (s *Server) handleListNoteShares(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
}
// 404, not 403: telling a stranger "not yours" still confirms the note exists.
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
shares, err := s.db.ListNoteShares(r.Context(), n.ID)
if err != nil {
s.internal(w, r, "list note shares", err)
return
}
// Regnet forfra, én gang, og læst to veje ud af det samme svar: hvem der har
// *denne* note, fordi en anden peger på den, og hvilke noter der omvendt følger
// med den her. To udregninger af det samme er to, der kan blive uenige.
//
// Og forfra frem for læst af basen, så panelet viser det, der gælder nu — også
// når linkene er skrevet af en anden og delingerne derfor ikke er regnet om
// siden.
following, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
via := map[string]string{}
for _, l := range following {
if l.NoteID == n.ID {
via[l.UserID] = l.Via
}
}
out := make([]noteShareJSON, 0, len(shares))
already := map[string]bool{}
for _, sh := range shares {
// Panelet skal kunne sige forskel: en deling, nogen har valgt, tages tilbage
// ved at fjerne den — en, der er fulgt med, ved at fjerne linket eller den
// deling, den kom fra. En knap, der lover det første og gør det andet, er
// værre end ingen knap.
out = append(out, noteShareJSON{
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
Role: string(sh.Role),
Linked: via[sh.User.ID] != "",
Via: via[sh.User.ID],
})
already[sh.User.ID] = true
}
// The picker is filled from the same response, so the panel opens complete in
// one round-trip. People already on the note are left out — they are in the list
// above it, not the "add someone" menu.
candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "share candidates", err)
return
}
cand := make([]personJSON, 0, len(candidates))
for _, p := range candidates {
if already[p.ID] {
continue
}
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
// den samme indbakke, og ingen af dem til at trække tilbage.
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
if err != nil {
s.internal(w, r, "list note invites", err)
return
}
pending := make([]noteInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"shares": out,
"candidates": cand,
"invites": pending,
"follows": followsOf(following, n.ID),
})
}
// followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én
// pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste
// følger med" er det samme, hvem det så end deles med.
func followsOf(all []store.LinkedShare, noteID string) []followJSON {
seen := map[string]bool{}
out := []followJSON{}
for _, l := range all {
if l.ViaID != noteID || seen[l.NoteID] {
continue
}
seen[l.NoteID] = true
out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title})
}
return out
}
type followJSON struct {
NoteID string `json:"note_id"`
Title string `json:"title"`
}
type noteShareJSON struct {
User personJSON `json:"user"`
Role string `json:"role"`
// Sat, når personen har noten, fordi en anden delt note peger på den — ikke
// fordi nogen har delt netop den. `via` er den note, der peger.
Linked bool `json:"linked"`
Via string `json:"via,omitempty"`
}
type shareNoteRequest struct {
UserID string `json:"user_id"`
// Email deles der med, når personen ikke er at finde i listen — enten fordi
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
// finde endnu.
Email string `json:"email"`
Role string `json:"role"`
}
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
// dukker op.
type noteInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleShareNote grants a person a role on a note, or changes the role they hold.
//
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
//
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
// vent på at de opretter sig, find noten frem, del den.
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
var req shareNoteRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
role := store.Role(req.Role)
if role == "" {
role = store.RoleViewer
}
if role != store.RoleViewer && role != store.RoleEditor {
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
return
}
me := userFrom(r.Context())
email := store.NormalizeEmail(req.Email)
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
// En adresse, der viser sig at høre til en konto, er den konto. At sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en blindgyde
// — samme regel som en projektinvitation følger.
if req.UserID == "" {
if !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
if email == store.NormalizeEmail(me.Email) {
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
return
}
existing, err := s.db.UserByEmail(r.Context(), email)
if err != nil {
s.inviteToNote(w, r, n, email, role)
return
}
req.UserID = existing.ID
}
// The recipient must be a real account and not the sharer themselves; ShareNote
// refuses the owner, and a made-up id should not reach it.
if req.UserID == me.ID {
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
return
}
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
s.storeError(w, r, "share note", err)
return
}
s.notifyNoteShared(r, n, req.UserID)
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
if err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"follows": followsOf(followed, n.ID),
// Personen sendes med retur, fordi fladen kan have delt med en adresse og
// derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
}
// inviteToNote sends a link that creates an account and the share at once.
//
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
// på den.
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
me := userFrom(r.Context())
// En anden invitation til den samme note i den samme indbakke er ikke en
// invitation mere: det er to links, der gør det samme, og kun det ene kan
// trækkes tilbage ad gangen.
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
if err != nil {
s.internal(w, r, "pending note invite", err)
return
}
if pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
return
}
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, r, "create note invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
emailed := false
if s.mail.Configured() {
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
s.log.Error("send note invite", "err", err, "to", email)
} else {
emailed = true
}
}
writeJSON(w, http.StatusCreated, map[string]any{
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
"link": link,
"emailed": emailed,
})
}
// handleDeleteNoteInvite withdraws an invitation before it is used.
//
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
// kan kun rækken, det hører til.
func (s *Server) handleDeleteNoteInvite(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
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, r, "delete note invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnshareNote takes a person's access away again.
func (s *Server) handleUnshareNote(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
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil {
s.internal(w, r, "unshare note", err)
return
}
// Det, der fulgte med, følger også med tilbage. Regnet forfra: de udledte rækker
// skrives om fra bunden, så en note, der stadig kan nås fra en anden deling,
// bliver stående, og en, der ikke kan, forsvinder.
if _, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID); err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+296
View File
@@ -0,0 +1,296 @@
package httpapi
import (
"net/http"
"testing"
)
// createNote makes a note as the current client and returns its id.
func (ts *testServer) createNote(t *testing.T, body string) string {
t.Helper()
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create note: status %d", resp.StatusCode)
}
id, _ := out["id"].(string)
if id == "" {
t.Fatal("create note returned no id")
}
return id
}
func userID(t *testing.T, ts *testServer, email string) string {
t.Helper()
u, err := ts.db.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("look up %s: %v", email, err)
}
return u.ID
}
// A note shared with a person shows up in their list, marked as theirs-from-
// somebody, and a viewer can read it but not change it.
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
// Before sharing, Sofie cannot see it at all.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
}
// Share it as a viewer.
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
_, list := other.do(t, "GET", "/api/v1/notes", nil)
notes, _ := list["notes"].([]any)
var found map[string]any
for _, raw := range notes {
n := raw.(map[string]any)
if n["id"] == noteID {
found = n
}
}
if found == nil {
t.Fatal("the shared note is not in the recipient's list")
}
if found["shared_with_me"] != true {
t.Errorf("the note is not marked shared_with_me: %v", found)
}
ownerObj, _ := found["owner"].(map[string]any)
if ownerObj == nil || ownerObj["name"] != "Kristian" {
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
}
// A viewer reads it.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
}
// But cannot change it.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
}
}
// An editor may change the text, but only the owner may delete the note or hand it
// to somebody else.
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Delt\noprindelig")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatalf("share as editor failed")
}
// The editor writes.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
}
// The editor cannot re-share it: giving access is the owner's alone.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
}
// The editor cannot delete it.
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
t.Error("an editor deleted the owner's note")
}
// The note still exists for the owner.
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
}
}
// Unsharing takes access away again, and only the owner can do it.
func TestUnshareRemovesAccess(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Hemmelig")
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
// A non-owner cannot unshare.
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
}
// The owner can.
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("owner unshare: %d", resp.StatusCode)
}
// And access is gone.
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
}
}
// The share list is owner-only and offers the instance's other people as
// candidates, minus those already on the note.
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "sofie@example.dk", "Sofie")
owner.newUser(t, "anders@example.dk", "Anders")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Plan")
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
shares, _ := out["shares"].([]any)
if len(shares) != 1 {
t.Fatalf("shares = %d, want 1", len(shares))
}
cands, _ := out["candidates"].([]any)
// Anders is a candidate; Sofie is already on the note and so is not.
names := map[string]bool{}
for _, c := range cands {
names[c.(map[string]any)["name"].(string)] = true
}
if !names["Anders"] {
t.Errorf("Anders is not offered as a candidate: %v", names)
}
if names["Sofie"] {
t.Error("somebody already on the note is offered as a candidate")
}
if names["Kristian"] {
t.Error("the owner is offered as a candidate to share with themselves")
}
}
// Sharing refuses a made-up person and the owner themselves.
func TestShareRejectsUnknownAndSelf(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
me := userID(t, owner, "kristian@example.dk")
noteID := owner.createNote(t, "# Note")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
t.Error("a note was shared at the owner role")
}
}
// De noter, en delt note peger på, følger med — og holder op igen.
//
// Det var meldt som en fejl den anden vej rundt: linkene i en delt note førte
// ingen steder hen, fordi det, de pegede på, ikke var delt. Prøven går hele vejen
// rundt om svaret på det — dybden, teksten der ændrer sig bagefter, det der ikke
// må følge med, og vejen tilbage.
func TestSharedNoteTakesItsLinksWithIt(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
// Prisliste peger videre på Leveringstider, så kæden er to led lang.
deep := owner.createNote(t, "# Leveringstider\nfire dage")
linked := owner.createNote(t, "# Prisliste\nog [[Leveringstider]]")
apart := owner.createNote(t, "# Bankoplysninger\nikke til deling")
noteID := owner.createNote(t, "# Aftale om levering\nse [[Prisliste]]")
reachable := func(id string) bool {
resp, _ := other.do(t, "GET", "/api/v1/notes/"+id, nil)
return resp.StatusCode == http.StatusOK
}
resp, out := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
// Svaret siger, hvad der fulgte med — hele kæden, ikke kun første led. En
// deling, der stille tog to noter mere med, ville være rigtig og alligevel ikke
// til at overskue.
follows, _ := out["follows"].([]any)
if len(follows) != 2 {
t.Fatalf("delingen nævnte %d noter, der fulgte med, want 2: %v", len(follows), out["follows"])
}
if !reachable(linked) {
t.Error("noten, den delte note peger på, fulgte ikke med")
}
if !reachable(deep) {
t.Error("kæden stoppede efter ét led")
}
if reachable(apart) {
t.Error("en note, ingen peger på, blev delt")
}
// Skrevet bagefter tæller også. Et link i dag i en note delt i går skal føre
// samme sted hen for begge.
senere := owner.createNote(t, "# Rabatter\nti procent")
if reachable(senere) {
t.Fatal("en helt ny note var delt, før noget pegede på den")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nse [[Prisliste]] og [[Rabatter]]"}); resp.StatusCode != http.StatusOK {
t.Fatal("kunne ikke skrive videre i den delte note")
}
if !reachable(senere) {
t.Error("et link skrevet efter delingen fulgte ikke med")
}
// Og vejen tilbage: tages delingen tilbage, følger det med, den tog med sig.
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("unshare: status %d", resp.StatusCode)
}
if reachable(linked) || reachable(deep) || reachable(senere) {
t.Error("det, der fulgte med delingen, blev stående, da den blev taget tilbage")
}
}
// En medredaktør kan ikke dele ejerens noter med sig selv ved at skrive et link.
//
// Det er den ene vej, "linkene følger med" kunne blive til en rettighedsfejl: et
// link peger på en titel, og enhver, der må rette i en delt note, kan skrive en
// titel. Derfor regnes delingerne kun om, når ejeren selv har gemt.
func TestAnEditorCannotShareTheOwnersNotesWithThemselves(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
secret := owner.createNote(t, "# Bankoplysninger\nreg og konto")
noteID := owner.createNote(t, "# Fælles\nintet link her")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("share as editor failed")
}
// Hun skriver linket selv, i den note hun har fået lov at rette i.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Fælles\nse [[Bankoplysninger]]"}); resp.StatusCode != http.StatusOK {
t.Fatal("medredaktøren kunne ikke skrive")
}
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+secret, nil); resp.StatusCode == http.StatusOK {
t.Error("en medredaktør delte ejerens note med sig selv ved at skrive et link")
}
}
+275 -10
View File
@@ -1,7 +1,9 @@
package httpapi
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
@@ -17,6 +19,7 @@ type notificationJSON struct {
ActorName string `json:"actor_name,omitempty"`
ProjectID string `json:"project_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
NoteID string `json:"note_id,omitempty"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
@@ -42,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
for _, n := range list {
out = append(out, notificationJSON{
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
@@ -73,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
// Detached from the request: a push round trip to Google or Mozilla has no
@@ -100,47 +104,308 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
}
}
// notifyAssigned tells somebody a task is now theirs.
//
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
// hvad de lige har gjort.
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
if t == nil || t.AssigneeID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
})
}
// notifyNoteShared tells somebody that a note is now theirs to read.
//
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
//
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
// ikke en ny note at læse.
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
if n == nil || userID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
})
}
// notifyProjectShared tells somebody that a project is now theirs to work in.
//
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
// opgaver til dig i det.
//
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
// handling — de skal ikke have besked om det, de lige selv gjorde.
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
if project == nil || userID == "" {
return
}
actor := userFrom(r.Context())
if actor.ID == userID {
return
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
//
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
if n == nil {
return
}
actor := userFrom(r.Context())
audience, err := s.db.NoteAudience(r.Context(), n.ID)
if err != nil {
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
return
}
title := actor.Name + " rettede en note"
body := n.Title
for _, userID := range audience {
if userID == actor.ID || except[userID] {
continue
}
// Fold first, and only write a row when there was nothing to fold into.
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
// hvor klokken siger to.
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
if err != nil {
s.log.Warn("fold note notification", "err", err, "note", n.ID)
continue
}
if folded {
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
// op og siger nu et andet navn. Uden det her opdager en åben fane først
// den sammenlagte rettelse ved næste genindlæsning.
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
continue
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.changed", Title: title, Body: body,
})
}
}
// 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) {
// pushOutcome is what happened, per device.
//
// It exists because "nothing arrives" was unanswerable. A push that a service
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
// service will not take, a browser that revoked permission without telling the
// server — and every one of them looked exactly like silence.
type pushOutcome struct {
Subscriptions int `json:"subscriptions"`
Sent int `json:"sent"`
Failed []pushFailure `json:"failed"`
}
// pushFailure names the service and quotes what it said.
//
// The host and not the endpoint: the rest of that URL is the credential the push
// service issued for this device, and it does not belong in an answer, a log line
// or a screenshot.
type pushFailure struct {
Service string `json:"service"`
Reason string `json:"reason"`
}
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
defer cancel()
var out pushOutcome
subs, err := s.db.ListPushSubscriptions(ctx, userID)
if err != nil || len(subs) == 0 {
return
return out
}
out.Subscriptions = len(subs)
public, private, err := s.vapidKeys(ctx)
if err != nil {
s.log.Warn("vapid keys", "err", err)
return
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
return out
}
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
target := "/projekt/" + projectID
if projectID == "" {
target = "/"
}
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
// stadig en besked.
unread, err := s.db.UnreadNotificationCount(ctx, userID)
if err != nil {
s.log.Warn("unread count for push", "err", err)
}
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
subject := s.vapidSubject()
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
}, payload, push.VAPID{
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
Public: public, Private: private, Subject: subject,
})
if err == nil {
out.Sent++
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
s.log.Warn("mark push delivered", "err", delErr)
}
continue
}
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
// Warn, not Debug.
//
// This was the one line that would have said why no notification ever
// arrived, and at Debug it is not printed on an instance running at the
// default level. A delivery that fails silently and a delivery that does
// not happen look the same from the outside, and the difference is the
// whole diagnosis.
reason := pushReason(sub.Endpoint, err)
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
out.Failed = append(out.Failed, pushFailure{
Service: pushHost(sub.Endpoint),
Reason: reason,
})
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
s.log.Warn("record push failure", "err", recErr)
}
}
return out
}
// pushReason is what went wrong, with the endpoint taken back out of it.
//
// Go wraps a transport failure as `*url.Error`, and its message is `Post
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
// endpoint, which is the credential the push service issued for that device. It
// would have gone into the answer, the log line and any screenshot of either. A
// test written to prove the host was reported instead is what found it.
//
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
// anyway: the next error type to carry it will not come with a warning.
func pushReason(endpoint string, err error) string {
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
err = urlErr.Err
}
reason := err.Error()
if endpoint != "" {
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
}
return reason
}
// pushHost is the service an endpoint belongs to, and nothing else from it.
func pushHost(endpoint string) string {
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" {
return "ukendt"
}
return u.Host
}
// handlePushTest sends one notification to this account's devices and says what
// happened to it.
//
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
// settings page said "on for this device" as soon as a subscription existed, and a
// refusal from the push service went into a Debug line nobody sees. There was no
// way to tell "the browser never subscribed" from "the service refused it" from
// "nothing has come due yet" — three different problems with one appearance.
//
// It goes through the same path as a real notification rather than a shortcut, so
// what it proves is the thing that actually has to work.
//
// The words come from the client, which is where the dictionaries are. A server
// that carries its own copy of "This is a test" carries it in one language, and
// this one deliberately keeps its prose in the interface — see
// `internal/i18n`. The text is only ever shown back to the person who asked for
// it, on their own devices.
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
title = "verdande"
}
// Bounded: this is written into a notification on somebody's lock screen, and
// a megabyte of title is not a notification.
if len(title) > 200 {
title = title[:200]
}
body := strings.TrimSpace(req.Body)
if len(body) > 500 {
body = body[:500]
}
user := userFrom(r.Context())
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
}
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
}
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
//
// It has to be one the service will actually accept, and Apple is the strict one:
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
// what the old fallback, mailto:verdande@example.invalid, was on an instance with
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
// the person waiting for the notification could tell.
//
// So a real address is used when there is one, and otherwise the instance's own
// https address — which is a valid VAPID subject, points at something that exists,
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
// the https URL a 201.
func (s *Server) vapidSubject() string {
from := strings.TrimSpace(s.cfg.SMTP.From)
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
return "mailto:" + from
}
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
return s.cfg.BaseURL
}
return "mailto:verdande@example.invalid"
}
// --- subscription endpoints -----------------------------------------------------------
type pushSubscriptionRequest struct {
+205
View File
@@ -0,0 +1,205 @@
package httpapi
import (
"net/http"
"testing"
)
// notifications reads somebody's bell.
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list notifications: status %d", resp.StatusCode)
}
list, _ := out["notifications"].([]any)
unread, _ := out["unread"].(float64)
return list, unread
}
func firstOf(t *testing.T, list []any) map[string]any {
t.Helper()
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
n, _ := list[0].(map[string]any)
return n
}
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
projectID, _ := project["id"].(string)
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
// medlem med det samme.
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "sofie@example.dk", "role": "editor"}); resp.StatusCode >= 300 {
t.Fatalf("invitér: %d", resp.StatusCode)
}
resp, task := owner.do(t, "POST", "/api/v1/tasks",
map[string]any{"project_id": projectID, "content": "ring til revisor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("opret opgave: %d", resp.StatusCode)
}
taskID, _ := task["id"].(string)
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
before, _ := notifications(t, other)
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel: %d", resp.StatusCode)
}
list, _ := notifications(t, other)
if len(list) != len(before)+1 {
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
}
n := firstOf(t, list)
if n["kind"] != "assigned" {
t.Errorf("kind = %v, want assigned", n["kind"])
}
if n["task_id"] != taskID {
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
}
if n["actor_name"] != "Kristian" {
t.Errorf("actor_name = %v", n["actor_name"])
}
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
// ændring, gør — og så bliver klokken ubrugelig på en dag.
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
t.Fatalf("ret: %d", resp.StatusCode)
}
if now, _ := notifications(t, other); len(now) != len(before)+1 {
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
}
// Og den, der tildeler sig selv, får ingenting.
me := userID(t, owner, "kristian@example.dk")
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
}
}
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
// så længe den ikke er læst.
func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Aftale om levering\nfirst")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
// Selve delingen er den første besked: Sofie skal vide, at noten er der, før
// nogen retter i den. Den tælles med i alt, hvad der følger.
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 ||
firstOf(t, list)["kind"] != "note.shared" {
t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list)
}
// Ejeren skriver. Sofie skal høre det.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
list, unread := notifications(t, other)
if unread != 2 || len(list) != 2 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "note.changed" || n["note_id"] != noteID {
t.Errorf("besked = %v", n)
}
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
// ens linjer i klokken.
for i := 0; i < 3; i++ {
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten igen")
}
}
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse",
len(list))
}
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
t.Fatal("markér som læst")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten efter læsningen")
}
if list, unread := notifications(t, other); len(list) != 3 || unread != 1 {
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread)
}
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
}
// Og den anden vej: Sofie skriver, ejeren hører det.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
t.Fatal("Sofie retter")
}
list, _ = notifications(t, owner)
if len(list) != 1 {
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
}
if firstOf(t, list)["actor_name"] != "Sofie" {
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
}
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
t.Fatal("stjernemarkér")
}
if list, _ := notifications(t, other); len(list) != 3 {
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
}
}
// En note, ingen deler, er ingen at give besked.
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
noteID := owner.createNote(t, "# Min egen\nintet")
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
}
}
+152 -14
View File
@@ -452,22 +452,100 @@ type memberJSON struct {
Role string `json:"role"`
}
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
// dukker op.
type projectInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleListMembers is everything the share panel needs, in one answer: who is on
// the project, who can be added, and who has been invited and not arrived.
//
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
//
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
projectID := chi.URLParam(r, "projectID")
members, err := s.db.ListMembers(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list members", err)
return
}
out := make([]memberJSON, 0, len(members))
already := map[string]bool{}
for _, m := range members {
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
already[m.UserID] = true
}
writeJSON(w, http.StatusOK, map[string]any{"members": out})
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, r, "get project", err)
return
}
already[project.OwnerID] = true
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "share candidates", err)
return
}
candidates := make([]personJSON, 0, len(people))
for _, p := range people {
if already[p.ID] {
continue
}
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
if err != nil {
s.internal(w, r, "list project invites", err)
return
}
pending := make([]projectInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"members": out,
"candidates": candidates,
"pending": pending,
})
}
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
//
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
// udløber, med et link, der virker hele tiden.
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
if err != nil {
s.storeError(w, r, "delete project invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type inviteRequest struct {
Email string `json:"email"`
Role string `json:"role"`
// UserID er den, man har peget på i listen. Email er den, man kender adressen
// på — og den er også den, der virker, når der ingen konto er endnu.
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
}
type inviteResponse struct {
@@ -478,8 +556,14 @@ type inviteResponse struct {
Emailed bool `json:"emailed"`
}
// handleInvite shares a project with somebody. An existing user is added directly;
// anybody else gets a link that creates their account and their membership at once.
// handleInvite shares a project with somebody.
//
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
// kontoen og medlemskabet på én gang.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
var req inviteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
if req.UserID == "" && !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
@@ -508,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
return
}
// Somebody who already has an account is added straight away: sending them
// through a signup form they cannot complete would be a dead end.
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
if existing.ID == project.OwnerID {
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
if req.UserID == "" {
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
req.UserID = existing.ID
}
}
if req.UserID != "" {
if req.UserID == project.OwnerID {
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
return
}
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
// tom plads, ingen kan fjerne.
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
s.internal(w, r, "add member", err)
return
}
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
writeJSON(w, http.StatusOK, map[string]any{"added": true})
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
// de kiggede — uden at noget sagde hvornår eller fra hvem.
s.notifyProjectShared(r, project, person.ID)
writeJSON(w, http.StatusOK, map[string]any{
"added": true,
// Personen sendes med retur, fordi fladen kan have delt med en adresse
// og derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
return
}
// Et andet link til den samme indbakke hjælper ingen: det første virker
// stadig, og nu er der to at holde styr på og to at trække tilbage.
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
return
}
@@ -590,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
// anybody removing themselves.
//
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
//
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
// overdragelse, og den findes ikke endnu.
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, r, "project owner", err)
return
}
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
// persons medlemskab, bekræfter at medlemskabet findes.
if me.ID != owner && userID != me.ID {
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner cannot be removed from their own project")
+236
View File
@@ -486,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
}
}
}
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
// beskytte ved at lade være med at vise dem.
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anden@example.dk", "Anden")
anden, 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": "Firma"})
projectID := project["id"].(string)
// Først står de i listen over dem, der kan tilføjes.
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if !hasPerson(before["candidates"], anden.ID) {
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
}
// Delt ved at pege, ikke ved at stave.
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": anden.ID, "role": "editor"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, added)
}
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
t.Errorf("user = %v, want Anden", added["user"])
}
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if hasPerson(after["candidates"], anden.ID) {
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
}
found := false
for _, raw := range after["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == anden.ID {
found = true
if m["role"] != "editor" {
t.Errorf("role = %v, want editor", m["role"])
}
}
}
if !found {
t.Errorf("Anden blev ikke medlem: %v", after["members"])
}
}
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(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)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
if hasPerson(body["candidates"], me.ID) {
t.Errorf("ejeren står som kandidat til sit eget projekt")
}
}
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
//
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
// godt som det første, og så er der to at trække tilbage i stedet for et.
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invitation: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
pending, _ := body["pending"].([]any)
if len(pending) != 1 {
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
}
first := pending[0].(map[string]any)
if first["email"] != "fremmed@example.dk" {
t.Errorf("email = %v", first["email"])
}
// Et andet link til den samme indbakke hjælper ingen.
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "DELETE",
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if p, _ := after["pending"].([]any); len(p) != 0 {
t.Errorf("invitationen står der stadig: %v", after["pending"])
}
}
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
// hasPerson siger, om en liste af personer indeholder et bestemt id.
func hasPerson(raw any, id string) bool {
list, _ := raw.([]any)
for _, item := range list {
if p, ok := item.(map[string]any); ok && p["id"] == id {
return true
}
}
return false
}
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anden := ts.newUser(t, "anden@example.dk", "Anden")
andenUser, 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": "Fælleshuset"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": andenUser.ID, "role": "editor"})
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
var shared map[string]any
for _, raw := range inbox["notifications"].([]any) {
if n := raw.(map[string]any); n["kind"] == "project.shared" {
shared = n
}
}
if shared == nil {
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
}
if shared["project_id"] != projectID {
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
shared["project_id"], projectID)
}
// Udgangen.
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
}
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
}
}
// En udgang for en selv er ikke en dør til de andres medlemskaber.
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
en := ts.newUser(t, "en@example.dk", "En")
ts.newUser(t, "to@example.dk", "To")
enUser, err := ts.db.UserByEmail(t.Context(), "en@example.dk")
if err != nil {
t.Fatal(err)
}
toUser, err := ts.db.UserByEmail(t.Context(), "to@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)
for _, u := range []string{enUser.ID, toUser.ID} {
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"user_id": u, "role": "editor"})
}
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
if len(members["members"].([]any)) != 3 {
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
}
}
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
func TestTheOwnerCannotLeaveTheirOwnProject(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)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
resp, _ := ts.do(t, "DELETE",
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
if resp.StatusCode != http.StatusConflict {
t.Errorf("status %d, want 409", resp.StatusCode)
}
}
+33
View File
@@ -0,0 +1,33 @@
package httpapi
import (
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
// every push to an Apple device was refused. With no real address, the instance's
// own https URL is used instead — valid, real, and accepted.
func TestVAPIDSubject(t *testing.T) {
cases := []struct {
name string
from string
baseURL string
want string
}{
{"a real address is used as-is", "todo@example.dk", "https://verdande.example.dk", "mailto:todo@example.dk"},
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
if got := s.vapidSubject(); got != tc.want {
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
}
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package httpapi
import (
"net"
"net/http"
"net/netip"
"strings"
"github.com/kristianwind/verdande/internal/config"
)
// realIP rewrites r.RemoteAddr to the client address a trusted proxy reported,
// and does nothing at all when the peer is not trusted.
//
// This replaces chi's middleware.RealIP, which is deprecated precisely because it
// believes a forwarded header from anyone: an attacker sets X-Forwarded-For to a
// new value on every request and gets a fresh rate-limit bucket each time, and
// writes whatever address they like into the audit log. The one control that
// exists for when a password is already gone is keyed on this address, so it has
// to be an address the caller cannot choose.
//
// The gate is the peer. A header is read only when the machine that opened the
// connection is one of cfg.TrustedProxies — a reverse proxy or tunnel the operator
// put there. A connection straight off the internet is trusted for nothing, so its
// header is ignored and it is keyed on the address it actually came from. Behind a
// proxy that overwrites the client's own header (nginx, Caddy and cloudflared all
// do) the value is then the real client and cannot be forged from outside.
func realIP(cfg *config.Config) func(http.Handler) http.Handler {
trusted := cfg.TrustedProxies
header := cfg.RealIPHeader
if header == "" {
header = "X-Forwarded-For"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := trustedClientIP(r, trusted, header); ip != "" {
// Port preserved off the original peer only for shape; downstream
// clientIP() splits it away, and the forwarded value carries none.
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
}
// trustedClientIP returns the forwarded client address if the peer may be believed,
// or "" to leave RemoteAddr untouched.
func trustedClientIP(r *http.Request, trusted []netip.Prefix, header string) string {
peer := r.RemoteAddr
if host, _, err := net.SplitHostPort(peer); err == nil {
peer = host
}
peerAddr, err := netip.ParseAddr(peer)
if err != nil || !isTrusted(peerAddr, trusted) {
return ""
}
raw := r.Header.Get(header)
if raw == "" {
return ""
}
// The leftmost entry is the original client. That entry is only as trustworthy
// as the proxy that wrote it — but the trusted proxy is exactly the one that
// replaces a client-supplied header, so on the deployments this is written for
// it is the real client, and on a direct connection we never reach this line.
first := raw
if i := strings.IndexByte(raw, ','); i >= 0 {
first = raw[:i]
}
first = strings.TrimSpace(first)
if _, err := netip.ParseAddr(first); err != nil {
return ""
}
return first
}
func isTrusted(addr netip.Addr, trusted []netip.Prefix) bool {
if !addr.IsValid() {
return false
}
addr = addr.Unmap()
for _, p := range trusted {
if p.Contains(addr) {
return true
}
}
return false
}
+94
View File
@@ -0,0 +1,94 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// The address the rate limiter keys on must be one the caller cannot choose. A
// forwarded header is believed only from a trusted proxy, and ignored from a
// stranger connecting directly — the difference between a control and a suggestion.
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
seen := ""
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = clientIP(r)
}))
cases := []struct {
name string
remoteAddr string
xff string
want string
}{
{
// A private peer is a proxy the operator put there: believe it.
name: "trusted proxy is believed",
remoteAddr: "10.1.2.3:5000",
xff: "203.0.113.9",
want: "203.0.113.9",
},
{
// A public peer connected straight to us; its header is a guess.
name: "public peer header ignored",
remoteAddr: "198.51.100.7:5000",
xff: "203.0.113.9",
want: "198.51.100.7",
},
{
// No header from a trusted proxy leaves the peer as the answer.
name: "trusted peer without header",
remoteAddr: "127.0.0.1:5000",
xff: "",
want: "127.0.0.1",
},
{
// A spoofed value from a stranger cannot become the key.
name: "spoof from outside does not win",
remoteAddr: "198.51.100.7:5000",
xff: "127.0.0.1",
want: "198.51.100.7",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
r.RemoteAddr = tc.remoteAddr
if tc.xff != "" {
r.Header.Set("X-Forwarded-For", tc.xff)
}
h.ServeHTTP(httptest.NewRecorder(), r)
if seen != tc.want {
t.Errorf("keyed on %q, want %q", seen, tc.want)
}
})
}
}
// "none" turns the trust off entirely, for an instance exposed directly.
func TestRealIPTrustNone(t *testing.T) {
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
seen := ""
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = clientIP(r)
}))
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
r.Header.Set("X-Forwarded-For", "203.0.113.9")
h.ServeHTTP(httptest.NewRecorder(), r)
if seen != "10.1.2.3" {
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
}
}
+95 -2
View File
@@ -63,7 +63,10 @@ type Server struct {
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
loginLimiter *limiter
// Sat kun af prøver; se icsClient().
icsFetch *http.Client
mailLimiter *limiter
hookLimiter *limiter
resetLimiter *limiter
// Second factors are counted against the *account*, not the address. An
// address bucket is defeated by anybody with more than one address, which is
@@ -91,6 +94,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// day. Sixty a minute never touches real delivery and still puts a ceiling
// on the one public write path that has none.
mailLimiter: newLimiter(60, time.Minute),
hookLimiter: newLimiter(60, time.Minute),
resetLimiter: newLimiter(5, time.Hour),
// Tighter than the password bucket, and for a whole hour. Somebody typing
// their own code gets it right on the first or second try; ten in an hour is
@@ -108,7 +112,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
// Not middleware.RealIP: that believes a forwarded header from anyone, which
// lets a caller pick the address the rate limiter and audit log key on. This
// one reads the header only from a trusted proxy — see realip.go.
r.Use(realIP(cfg))
r.Use(requestLogger(log))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
@@ -138,6 +145,20 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/inbound/mail",
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
// En krog ind i indbakken: en URL, et andet program kan skubbe en linje tekst
// til. Uden for /api/v1 og uden for sessionen af samme grund som feedet og
// posten ovenfor — den, der kalder, er en genvej på en telefon eller et script,
// og kan ikke logge ind, så tokenet i stien er hele legitimationen.
//
// Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet:
// forudindlæsning i en browser, en linkkontrol i en chat og en historik, der
// bliver besøgt igen, ville alle lave opgaver, ingen har bedt om.
//
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
// for den indgående post, og omvendt.
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
// 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
@@ -295,6 +316,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/key", s.handlePushKey)
r.Post("/subscribe", s.handleSubscribePush)
r.Post("/unsubscribe", s.handleUnsubscribePush)
// One notification, to this account's own devices, reporting what
// each of them did with it. The answer to "der kommer ikke nogen".
r.Post("/test", s.handlePushTest)
})
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
@@ -311,6 +335,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
r.Get("/hook-url", s.handleGetHookURL)
r.Post("/hook-url/rotate", s.handleRotateHookURL)
// Personal API tokens. Behind requireSession rather than the ambient
// auth: a token must not be able to mint or revoke another.
r.Route("/tokens", func(r chi.Router) {
@@ -325,6 +352,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
// Dagens plan: indstillingen, og en knap til at se den nu. En
// indstilling, man skal vente til i morgen for at se virkningen af,
// er en indstilling, ingen tør slå til.
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
// det er bygget på.
r.Post("/ask", s.handleAIAsk)
})
r.Get("/version", s.handleVersion)
@@ -340,6 +384,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/", s.handleCreateNote)
r.Post("/import", s.handleImportNotes)
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
// Sharing a note with a person directly — its own access list, beside
// the project path. Owner only; see notes_share_handlers.go.
r.Get("/{noteID}/shares", s.handleListNoteShares)
r.Post("/{noteID}/shares", s.handleShareNote)
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
// En invitation til en note er en deling, der venter på en konto.
// Den kan tages tilbage indtil den bruges; se handleShareNote.
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
@@ -389,6 +441,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/authorize", s.handleCalendarAuthorize)
r.Post("/sync", s.handleCalendarSyncNow)
r.Get("/events", s.handleCalendarEvents)
// Abonnementer: en kalender, man kun har en adresse til. Flere pr.
// person, hvor Google er én — det er adressen, der er identiteten,
// hvor tokenet er det for OAuth.
r.Post("/subscriptions", s.handleSubscribeCalendar)
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
})
r.Route("/import", func(r chi.Router) {
@@ -435,6 +493,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
r.Delete("/members/{userID}", s.handleRemoveMember)
r.Get("/activity", s.handleActivity)
r.Group(func(r chi.Router) {
@@ -448,8 +509,8 @@ 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.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
})
@@ -471,6 +532,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// installation and it either phones home or it does not.
r.Get("/beacon/settings", s.handleBeaconStatus)
r.Put("/beacon/settings", s.handleSetBeacon)
// Beskeden om, hvad der bliver sendt, og svaret på den. I samme
// gruppe som resten: den, der kan slå beaconet fra, er den, der
// skal have beskeden, og at spørge alle andre ville være at
// stille et spørgsmål, de ikke kan svare på.
r.Get("/beacon/notice", s.handleBeaconNotice)
r.Post("/beacon/notice", s.handleAckBeaconNotice)
// 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.
@@ -532,6 +599,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/complete", s.handleCompleteTask)
r.Post("/reopen", s.handleReopenTask)
r.Post("/move", s.handleMoveTask)
r.Post("/snooze", s.handleSnoozeTask)
})
})
})
@@ -654,6 +722,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", s.csp)
// The session lives in a __Host- cookie that only exists over HTTPS, so the
// whole security model already assumes TLS. HSTS is what makes that
// assumption hold against a network attacker: without it, the very first
// navigation can be stripped to http:// and the cookie handed over in the
// clear. Gated on the same condition as the cookie's Secure flag, so a
// plain-HTTP dev instance is never told to demand a certificate it cannot
// present. Two years, subdomains included; no preload directive, because
// asking to be hard-coded into browsers is a decision the operator makes
// for their domain, not one a self-hosted app makes for them.
if s.secureCookies() {
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
// verdande uses none of the powerful browser features, so the safest policy
// is to turn them all off: an injected element cannot ask for the camera,
// the microphone or the location if the page itself has already declined
// them. Named explicitly rather than with a wildcard because the wildcard
// form of this header is not universally understood, and a directive a
// browser ignores is not a control.
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
"payment=(), usb=(), interest-cohort=()")
// Isolate the browsing context: a window this page opened, or one that
// opened it, cannot keep a handle across the origin boundary. verdande
// never scripts another window, so there is nothing to lose and a whole
// class of cross-window attacks to close.
h.Set("Cross-Origin-Opener-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
+88 -17
View File
@@ -16,23 +16,24 @@ import (
)
type taskJSON struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
SectionID string `json:"section_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Content string `json:"content"`
Description string `json:"description,omitempty"`
Priority int `json:"priority"`
DueDate string `json:"due_date,omitempty"`
DueDatetime string `json:"due_datetime,omitempty"`
DurationMin *int `json:"duration_min,omitempty"`
Recurrence string `json:"recurrence_rule,omitempty"`
RepeatText string `json:"recurrence_text,omitempty"`
AssigneeID string `json:"assignee_id,omitempty"`
Labels []string `json:"labels"`
Completed bool `json:"completed"`
CompletedAt string `json:"completed_at,omitempty"`
CreatedBy string `json:"created_by"`
ID string `json:"id"`
ProjectID string `json:"project_id"`
SectionID string `json:"section_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Content string `json:"content"`
Description string `json:"description,omitempty"`
Priority int `json:"priority"`
DueDate string `json:"due_date,omitempty"`
DueDatetime string `json:"due_datetime,omitempty"`
DurationMin *int `json:"duration_min,omitempty"`
Recurrence string `json:"recurrence_rule,omitempty"`
RepeatText string `json:"recurrence_text,omitempty"`
AssigneeID string `json:"assignee_id,omitempty"`
Labels []string `json:"labels"`
Completed bool `json:"completed"`
CompletedAt string `json:"completed_at,omitempty"`
SnoozedUntil string `json:"snoozed_until,omitempty"`
CreatedBy string `json:"created_by"`
// 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"`
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
if t.CompletedAt != nil {
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
}
if t.SnoozedUntil != nil {
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
}
return j
}
@@ -241,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
t.Labels = req.Labels
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
// Lavet til en anden med det samme. Det er den samme besked som at blive
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
// ind i "det her er dit nu" giver besked og den anden ikke.
s.notifyAssigned(r, t)
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
}
@@ -268,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
}
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
// det hver gang nogen retter en overskrift.
wasAssigned := ""
if req.AssigneeID != nil {
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
wasAssigned = before.AssigneeID
}
}
u := store.TaskUpdate{
Description: req.Description, Priority: req.Priority,
SectionID: req.SectionID, ParentID: req.ParentID,
@@ -320,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
s.notifyAssigned(r, t)
}
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
@@ -698,3 +720,52 @@ func parseLimit(raw string, def, max int) int {
}
return n
}
type snoozeRequest struct {
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
Until string `json:"until"`
}
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
// but when the task wants to be seen — its due date and everything else stay put.
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
var req snoozeRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
taskID := chi.URLParam(r, "taskID")
user := userFrom(r.Context())
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
var until *time.Time
if strings.TrimSpace(req.Until) != "" {
parsed, perr := time.Parse(time.RFC3339, req.Until)
if perr != nil {
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
return
}
until = &parsed
}
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
s.storeError(w, r, "snooze task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
verb := "task.snoozed"
if until == nil {
verb = "task.unsnoozed"
}
s.activity(r, t.ProjectID, t.ID, verb, nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
+90
View File
@@ -285,6 +285,49 @@ func TestTodayAndUpcoming(t *testing.T) {
}
}
// The horizon is the list's, and it is asked for per request: seven days is the
// default, thirty is what Kommende offers for "when does this month get busy", and
// anything past the cap is trimmed rather than refused — a client that asks for a
// year gets a month, not a 400.
func TestUpcomingHorizon(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "om tyve dage",
"due_date": userDate(t, 20),
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, out)
}
count := func(query string) (int, int) {
t.Helper()
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
days, _ := view["days"].([]any)
var tasks int
for _, d := range days {
day, _ := d.(map[string]any)
list, _ := day["tasks"].([]any)
tasks += len(list)
}
return len(days), tasks
}
if n, found := count(""); n != 7 || found != 0 {
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
}
if n, found := count("?days=30"); n != 30 || found != 1 {
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
}
if n, _ := count("?days=365"); n != 31 {
t.Errorf("days=365 returned %d days, want it capped at 31", n)
}
if n, _ := count("?days=nonsense"); n != 7 {
t.Errorf("an unreadable days returned %d days, want the default 7", n)
}
}
func TestSearchFindsAcrossProjects(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
@@ -1817,3 +1860,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
}
}
}
// A snoozed task sinks to the bottom of the list and greys until its time; waking
// it puts it back. The list order is what the row's dimming rides on.
func TestSnoozeSinksAndWakes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
aID, _ := a["id"].(string)
// Snooze Alfa two days out.
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
if resp.StatusCode != http.StatusOK {
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
}
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
t.Error("the response does not carry snoozed_until")
}
// Alfa now sorts after Bravo, though it was created first.
order := func() []string {
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
var out []string
for _, raw := range list["tasks"].([]any) {
out = append(out, raw.(map[string]any)["content"].(string))
}
return out
}
got := order()
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
t.Errorf("snoozed task did not sink: order = %v", got)
}
// Wake it, and it comes back above Bravo.
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
if resp.StatusCode != http.StatusOK {
t.Fatalf("wake: %d %v", resp.StatusCode, body)
}
if s, _ := body["snoozed_until"].(string); s != "" {
t.Errorf("waking left snoozed_until set: %q", s)
}
if got := order(); got[0] != "Alfa" {
t.Errorf("woken task did not return: order = %v", got)
}
}
+9 -1
View File
@@ -17,6 +17,9 @@ import (
type totpSetupResponse struct {
Secret string `json:"secret"`
URI string `json:"uri"`
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
// hand on a device that cannot scan.
QR string `json:"qr"`
}
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "store totp secret", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
qrSVG, err := auth.TOTPQRSVG(uri)
if err != nil {
s.internal(w, r, "render totp qr", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
}
type totpConfirmResponse struct {
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"context"
"strings"
"github.com/kristianwind/verdande/internal/store"
"github.com/kristianwind/verdande/internal/update"
)
// Besked til administratorerne, når der er kommet en ny version.
//
// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en
// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde
// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af
// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter.
//
// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om,
// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan
// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder.
const updateScope = "update"
// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer,
// så en time mellem opslagene her koster ikke noget — og en instans, der har været
// slukket, opdager det ved den første time, den er vågen.
func (s *Server) NotifyOfUpdates(ctx context.Context) error {
return s.notifyOfUpdate(ctx, s.updates.Status(ctx))
}
// notifyOfUpdate tager status som argument frem for at hente den, så den kan
// prøves uden at spørge GitHub om noget.
func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error {
// Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke
// blive til en besked ad bagvejen.
if status.Disabled || !status.Available || status.Latest == "" {
return nil
}
stored, err := s.db.InstanceSettings(ctx, updateScope)
if err != nil {
return err
}
// Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if last, _ := stored["notified_version"].(string); last == status.Latest {
return nil
}
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
title := updateTitle(status.Latest)
body := firstLineOf(status.Notes)
for _, u := range users {
if !u.IsAdmin {
continue
}
s.notifyDirect(ctx, &store.Notification{
UserID: u.ID, Kind: "update.available", Title: title, Body: body,
})
}
// Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en
// fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.
return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{
"notified_version": status.Latest,
})
}
// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og
// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra
// `kind` — se KINDS i Notifications.svelte.
func updateTitle(version string) string {
return "verdande " + version + " er klar"
}
// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop.
//
// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det,
// en notifikation er til: den skal sige, at der er noget, og hvor man læser det.
func firstLineOf(notes string) string {
for _, line := range strings.Split(notes, "\n") {
if line = strings.TrimSpace(line); line != "" {
return firstWords(line, 30)
}
}
return ""
}
+127
View File
@@ -0,0 +1,127 @@
package httpapi
import (
"testing"
"github.com/kristianwind/verdande/internal/update"
)
// En ny version siger til af sig selv. Uden det stod svaret nederst på en
// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at
// der var noget at se efter.
func TestANewVersionTellsTheAdministrator(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
status := update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0",
}
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("besked om ny version: %v", err)
}
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "update.available" {
t.Errorf("besked = %v", n)
}
// Titlen bærer versionen, fordi den også bliver til en push-besked, og push
// har ingen ordbog at slå sætningen op i.
if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" {
t.Errorf("titel = %q", title)
}
// Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("den samme version gav %d beskeder, want 1", len(list))
}
// Og den næste version er en ny ting at få at vide.
status.Latest = "v0.46.0"
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 2 {
t.Errorf("en ny version gav %d beskeder, want 2", len(list))
}
}
// Kun administratorer. En besked om, at serveren er bagud, er en besked om en
// andens arbejde, hvis man ikke selv kan gøre noget ved den.
func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "sofie@example.dk", "Sofie")
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list))
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("administratoren fik %d beskeder, want 1", len(list))
}
}
// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen.
func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, status := range []update.Status{
{Current: "v0.44.0", Disabled: true},
{Current: "v0.44.0", Available: false},
{Current: "v0.44.0", Available: true, Latest: ""},
} {
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("%v: %v", status, err)
}
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list))
}
}
// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det,
// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne
// klikkes.
func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
n := firstOf(t, mustNotifications(t, ts))
for _, key := range []string{"note_id", "task_id", "project_id"} {
if v, ok := n[key]; ok && v != "" {
t.Errorf("%s = %v, want ingenting", key, v)
}
}
}
func mustNotifications(t *testing.T, ts *testServer) []any {
t.Helper()
list, _ := notifications(t, ts)
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
return list
}
+7 -2
View File
@@ -51,7 +51,8 @@ type upcomingDay struct {
Tasks []taskJSON `json:"tasks"`
}
// handleUpcoming returns the next seven days, one entry per day.
// handleUpcoming returns the next `days` days, one entry per day, seven by default
// and at most a month.
//
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
// missing from the response because nothing is due would collapse the layout and
@@ -65,10 +66,14 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
from := start.Format("2006-01-02")
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
// The row limit follows the window rather than sitting at a constant, or a
// month asked for with the same ceiling as a week would truncate the last days
// silently — and a day cut off the end of the answer looks exactly like a day
// with nothing due.
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DueFrom: from,
DueBefore: to,
Limit: 1000,
Limit: 200 * days,
})
if err != nil {
s.internal(w, r, "upcoming", err)
+215 -13
View File
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
for _, t := range cal.Tasks {
writeTodo(&b, t, cal.Domain)
// A dated task also goes out as an event, for clients that ignore VTODO.
if t.DueDatetime != nil && !t.Completed {
// A dated task also goes out as an event, for the calendar apps that ignore
// VTODO — which is all of them: Apple Calendar and Google both drop a
// subscribed to-do on the floor and show only events. A task with a clock
// time becomes a timed event; one with only a date, an all-day event. Once
// this was limited to timed tasks, so a date-only task — most of them —
// simply never appeared in the calendar it was subscribed into.
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
writeEvent(&b, t, cal.Domain)
}
}
@@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) {
}
func writeEvent(b *strings.Builder, t Task, domain string) {
start := *t.DueDatetime
// Without a stated duration a task is shown as half an hour. A zero-length
// event collapses to an invisible sliver in most calendar grids.
minutes := 30
if t.DurationMin != nil && *t.DurationMin > 0 {
minutes = *t.DurationMin
}
end := start.Add(time.Duration(minutes) * time.Minute)
b.WriteString("BEGIN:VEVENT\r\n")
// A distinct UID suffix: the event and the to-do describe the same task, and
// sharing a UID would make a client treat them as two versions of one item.
writeLine(b, "UID:"+t.ID+"-event@"+domain)
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
writeLine(b, "DTSTART:"+utc(start))
writeLine(b, "DTEND:"+utc(end))
if t.DueDatetime != nil {
start := *t.DueDatetime
// Without a stated duration a task is shown as half an hour. A zero-length
// event collapses to an invisible sliver in most calendar grids.
minutes := 30
if t.DurationMin != nil && *t.DurationMin > 0 {
minutes = *t.DurationMin
}
writeLine(b, "DTSTART:"+utc(start))
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
} else {
// A date with no time is an all-day event. DTEND is exclusive — a one-day
// event ends on the following date — which is what keeps it a single day in
// the grid rather than bleeding into the next.
day := strings.ReplaceAll(t.DueDate, "-", "")
writeLine(b, "DTSTART;VALUE=DATE:"+day)
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
}
writeLine(b, "SUMMARY:"+escape(t.Content))
if t.Description != "" {
writeLine(b, "DESCRIPTION:"+escape(t.Description))
@@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) {
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
// DTEND. A malformed date is returned as its own compacted form rather than
// dropped, so a task never loses its event over a bad string.
func nextDay(date string) string {
d, err := time.Parse("2006-01-02", date)
if err != nil {
return strings.ReplaceAll(date, "-", "")
}
return d.AddDate(0, 0, 1).Format("20060102")
}
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
// a value separator in iCalendar, so an unescaped one silently truncates the
// summary at that character in every client.
@@ -367,3 +392,180 @@ func unescape(s string) string {
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
return r.Replace(s)
}
// --- reading a whole calendar ------------------------------------------------------
// Event is one VEVENT, reduced to what a grid needs — the same shape the Google
// side already stores, so the two arrive in the database identically.
//
// Days at both ends, inclusive, because that is the question a grid asks: which
// cells does this cover. StartsAt keeps the stamp as the file wrote it, offset and
// all: it is what the chip shows, and reparsing it here would ask *this process*
// what time zone it is in.
type Event struct {
UID string
Summary string
Location string
URL string
AllDay bool
StartsAt string // RFC3339, empty for an all-day event
EndsAt string
StartDay string // YYYY-MM-DD
EndDay string
}
// Subscribed is what an .ics file somebody subscribed to says.
//
// Its own name rather than `Calendar`: that one is the *writing* side — what
// verdande publishes as a feed — and the two are different documents that happen
// to share a format. One name for both would be a promise that a round trip
// through this package is lossless, which it is not and does not need to be.
type Subscribed struct {
Name string
TimeZone string
Events []Event
}
// ParseCalendar reads an .ics file that somebody subscribed to.
//
// Forgiving in the same way ParseVTODO is, and for a sharper reason: this file was
// written by somebody else's software and is fetched on a schedule with nobody
// watching. One property this does not understand must not cost the other four
// hundred events in the file — so anything unrecognised is skipped, and an event
// without the two things a grid cannot do without (a uid and a start) is dropped
// rather than stored half-made.
//
// # What it deliberately does not do
//
// **Recurrence is not expanded.** RRULE in a subscribed calendar means "and then
// every Tuesday until 2031", and expanding it here would mean writing a year of
// rows per rule and re-deciding what happens when the file changes. A recurring
// event is stored as its first occurrence, which is honest — it is what the file
// says on its own terms — and the day it matters, the answer is an expander shared
// with `internal/recurrence`, not a second one written here.
//
// **Time zones are not resolved.** A DTSTART with TZID=Europe/Copenhagen is stored
// with the offset the file carries or, when it carries none, as a local stamp. The
// alternative is shipping a tz database and deciding what to do when the file
// names a zone this build has never heard of. The day that matters, it is one
// place.
func ParseCalendar(body string) Subscribed {
var cal Subscribed
var current *Event
depth := 0
for _, line := range unfold(body) {
name, params, value := splitLine(line)
upper := strings.ToUpper(name)
switch upper {
case "BEGIN":
if strings.EqualFold(value, "VEVENT") {
current = &Event{}
}
depth++
continue
case "END":
depth--
if strings.EqualFold(value, "VEVENT") && current != nil {
if current.UID != "" && current.StartDay != "" {
cal.Events = append(cal.Events, *current)
}
current = nil
}
continue
}
if current == nil {
// Calendar-level properties. X-WR-CALNAME is what every publisher uses
// to say what the calendar is called; there is no standard property for
// it, which is why a non-standard one is read here.
switch upper {
case "X-WR-CALNAME":
cal.Name = unescape(value)
case "X-WR-TIMEZONE":
cal.TimeZone = value
}
continue
}
switch upper {
case "UID":
current.UID = value
case "SUMMARY":
current.Summary = unescape(value)
case "LOCATION":
current.Location = unescape(value)
case "URL":
current.URL = value
case "DTSTART":
current.AllDay = isDateOnly(params, value)
current.StartDay, current.StartsAt = readStamp(params, value)
case "DTEND":
day, at := readStamp(params, value)
current.EndsAt = at
// DTEND is exclusive for an all-day event: a one-day event ends on the
// next morning. Stored inclusive, or every all-day event would cover one
// cell too many — and a public holiday would eat the day after it.
if current.AllDay {
day = shiftDay(day, -1)
}
current.EndDay = day
}
}
// A missing DTEND means the event ends when it starts. Filled after the loop
// rather than inside it, because DTEND may arrive before DTSTART in a file
// nobody promised would be in order.
for i := range cal.Events {
if cal.Events[i].EndDay == "" || cal.Events[i].EndDay < cal.Events[i].StartDay {
cal.Events[i].EndDay = cal.Events[i].StartDay
}
}
return cal
}
// isDateOnly reports whether a stamp is a day rather than a moment. VALUE=DATE
// says so outright; an eight-character value says it by being one.
func isDateOnly(params, value string) bool {
if strings.Contains(strings.ToUpper(params), "VALUE=DATE") {
return true
}
return len(value) == 8 && !strings.ContainsAny(value, "T")
}
// readStamp returns the day and, for a timed event, the moment as RFC3339.
//
// Three shapes exist in the wild: 20260821 (a day), 20260821T140000Z (UTC), and
// 20260821T140000 with a TZID parameter (a local time in a named zone). The third
// is kept as written, without a zone: resolving it needs a tz database, and a
// stamp that says 14:00 is closer to the truth than one this process has moved.
func readStamp(params, value string) (day, at string) {
value = strings.TrimSpace(value)
if len(value) < 8 {
return "", ""
}
day = value[0:4] + "-" + value[4:6] + "-" + value[6:8]
if len(value) < 15 || value[8] != 'T' {
return day, ""
}
clock := value[9:11] + ":" + value[11:13] + ":" + value[13:15]
switch {
case strings.HasSuffix(value, "Z"):
return day, day + "T" + clock + "Z"
default:
// A local stamp, with or without a TZID. Written without an offset, which
// is what the interface already treats as "the clock the file said".
return day, day + "T" + clock
}
}
// shiftDay moves a YYYY-MM-DD by whole days.
func shiftDay(day string, by int) string {
t, err := time.Parse("2006-01-02", day)
if err != nil {
return day
}
return t.AddDate(0, 0, by).Format("2006-01-02")
}
+116 -9
View File
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
// or a feed is useless in the calendar most people actually keep.
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
func TestDatedTasksBecomeEvents(t *testing.T) {
out := Render(sample())
if strings.Count(out, "BEGIN:VEVENT") != 1 {
t.Fatalf("got %d VEVENTs, want one for the single timed task",
strings.Count(out, "BEGIN:VEVENT"))
// Every dated, unfinished task becomes an event, so it shows in a calendar app
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
// date and no event.
if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 {
t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got)
}
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
t.Error("the event has no start")
// The timed one keeps its clock and its 45-minute duration.
if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") {
t.Error("the timed event's start or duration is wrong")
}
// 45 minutes of stated duration.
if !strings.Contains(out, "DTEND:20260310T094500Z") {
t.Error("the event's end does not reflect the task's duration")
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
// they sit on their own day rather than bleeding into the next.
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") {
t.Error("the date-only event is not a single all-day event")
}
// A recurring date-only task carries its rule onto the event.
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
t.Error("the recurring task's event has no RRULE")
}
// The event must not share the to-do's UID, or clients treat them as one item.
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
@@ -220,3 +229,101 @@ func TestFilename(t *testing.T) {
}
}
}
// En abonnementskalender, som den ser ud fra nogen andens software.
//
// Alle fire former, der findes i naturen, i én fil: en heldagsbegivenhed, en med
// et UTC-stempel, en med TZID og lokal tid, og en over flere dage. Plus en, der
// mangler det, et gitter ikke kan undvære, og en foldet linje — begge dele
// kommer, og de kommer uden varsel.
func TestParseCalendarReadsTheFourShapesThatExist(t *testing.T) {
body := "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"X-WR-CALNAME:Helligdage\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:heldag@example.dk\r\n" +
"DTSTART;VALUE=DATE:20260824\r\n" +
"DTEND;VALUE=DATE:20260825\r\n" +
"SUMMARY:Grundlovsdag\r\n" +
"END:VEVENT\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:utc@example.dk\r\n" +
"DTSTART:20260825T120000Z\r\n" +
"DTEND:20260825T133000Z\r\n" +
`SUMMARY:Møde med en meget lang titel\, der er foldet over to lin` + "\r\n" + " jer\r\n" +
"END:VEVENT\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:lokal@example.dk\r\n" +
"DTSTART;TZID=Europe/Copenhagen:20260826T090000\r\n" +
"DTEND;TZID=Europe/Copenhagen:20260826T100000\r\n" +
"SUMMARY:Tandlæge\r\n" +
"END:VEVENT\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:flerdage@example.dk\r\n" +
"DTSTART;VALUE=DATE:20260901\r\n" +
"DTEND;VALUE=DATE:20260904\r\n" +
"SUMMARY:Ferie\r\n" +
"END:VEVENT\r\n" +
"BEGIN:VEVENT\r\n" +
"SUMMARY:Uden uid og uden start\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
cal := ParseCalendar(body)
if cal.Name != "Helligdage" {
t.Errorf("kalenderens navn = %q", cal.Name)
}
if len(cal.Events) != 4 {
t.Fatalf("%d begivenheder, want 4 — den femte mangler både uid og start", len(cal.Events))
}
// Heldag. DTEND er eksklusiv i filen: en endagsbegivenhed slutter næste morgen.
// Gemmes inklusivt, ellers dækker hver eneste heldagsbegivenhed én celle for
// meget — og en helligdag æder dagen efter sig.
one := cal.Events[0]
if !one.AllDay || one.StartDay != "2026-08-24" || one.EndDay != "2026-08-24" {
t.Errorf("heldag: %+v", one)
}
if one.StartsAt != "" {
t.Errorf("en heldagsbegivenhed fik et klokkeslæt: %q", one.StartsAt)
}
// UTC, og den foldede linje samlet igen.
two := cal.Events[1]
if two.StartsAt != "2026-08-25T12:00:00Z" || two.EndsAt != "2026-08-25T13:30:00Z" {
t.Errorf("utc: %+v", two)
}
if two.Summary != "Møde med en meget lang titel, der er foldet over to linjer" {
t.Errorf("den foldede linje blev ikke samlet: %q", two.Summary)
}
// Lokal tid med TZID: klokkeslættet står, som filen skrev det. At flytte det
// her ville spørge *denne* proces, hvilken tidszone den er i.
three := cal.Events[2]
if three.StartsAt != "2026-08-26T09:00:00" {
t.Errorf("lokal: %q", three.StartsAt)
}
// Flere dage, begge ender inklusive.
four := cal.Events[3]
if four.StartDay != "2026-09-01" || four.EndDay != "2026-09-03" {
t.Errorf("ferie: %s til %s", four.StartDay, four.EndDay)
}
}
// En fil, der ikke er en kalender, må ikke koste noget. Den hentes på et skema
// uden nogen, der kigger med, og en fejl her ville være en sweep, der stopper.
func TestParseCalendarSurvivesRubbish(t *testing.T) {
for _, body := range []string{
"",
"ikke en kalender overhovedet",
"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
"BEGIN:VEVENT\r\nDTSTART:x\r\nEND:VEVENT\r\n",
"BEGIN:VEVENT\r\nUID:a\r\nDTSTART:2026\r\nEND:VEVENT\r\n",
} {
if got := ParseCalendar(body); len(got.Events) != 0 {
t.Errorf("%q gav %d begivenheder", body, len(got.Events))
}
}
}

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