Compare commits

...
150 Commits
Author SHA1 Message Date
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 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
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
Kristian WindandClaude Opus 5 ae673086bc feat(tasks): tid på en opgave, og et træk i kalenderen sætter den
Release / Build and publish image (push) Failing after 9m58s
Bedt om: "jeg vil have tid på opgaver, og jeg vil kunne justere dem i kalenderen."

Serveren kunne det hele i forvejen — `due_date` og `due_time` læses sammen og
bliver til et `due_datetime` i kontoens tidszone. Det var fladen, der ikke havde
nogen vej til at sige det.

**Et felt i opgaveruden.** Tomt, indtil nogen sætter det: en opgave uden
klokkeslæt er det almindelige, og et felt, der åbner på 00:00, påstår, at noget
skal ske ved midnat.

**Og en fejl, der lå og ventede.** Serveren læser en dato uden en tid som "ryd
tiden". Så et gem af datoen alene tog klokkeslættet med sig — man rettede dagen og
mistede timen — og det samme gjorde hvert eneste træk i kalenderen. Ingen af
delene sagde noget.

`reschedule` siger nu tiden højt, tre måder, fordi de to første ligner hinanden i
JavaScript og derfor skal skilles ad ét sted frem for at blive forvekslet fire:

    undefined   behold den, opgaven har   — månedens ruder, hvor der ingen tid er at læse
    ''          ryd den                   — heldagsbåndet
    'HH:MM'     sæt den                   — et sted på døgnet

**Hvor på søjlen man slipper, er klokkeslættet.** Målt mod søjlens egen kasse frem
for mod siden, så det bliver rigtigt uanset hvor gitteret sidder og hvor langt der
er rullet. Rundet til kvarter: minuttet under markøren er ikke en oplysning, nogen
har — pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
det blev valgt.

Prøven går hele vejen: sæt tiden, genindlæs og se at serveren tog den, ret datoen
og se at timen bliver, find opgaven på sit klokkeslæt i ugegitteret, og træk den op
i båndet igen. To ting, den lærte undervejs, står skrevet ved den: et træk over
fire hundrede pixels skal gå i skridt, ellers har browseren for få mellemstationer
til at holde gesten i live — og klokken skal være tidlig, for klokken fjorten
ligger under skærmkanten, og at måle noget dernede ruller siden væk under målet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:29:03 +02:00
Kristian WindandClaude Opus 5 e29fd04abd docs: noternes opbygning, skrevet til at blive løftet ud
Hvordan noter er bygget her: modellen, syntaksen, søgningens tre lag,
editorkontrakten, vedhæftninger, ind- og udførsel — og begrundelserne, som er den
del, der ikke kan læses ud af skemaet.

Den begynder med fjorten spørgsmål, der skal besvares før der skrives kode, og
siger det til den, der læser den, med rene ord: gæt ikke en standard. Grunden
står i filen — omtrent en tredjedel af designet her er arvet fra de omgivelser,
noterne bor i, og løftet ind i et program med andre omgivelser uden at afgøre
hvad der skal med, giver noget, der virker en uge og derefter slås med én.

Den siger også, hvad der ikke virker: notekroppe i klartekst i hver
sikkerhedskopi, import der aldrig fletter, ingen sync. En specifikation, der kun
beskriver det, der virker, lyver ved fortielse.

I repoet frem for uden for det, som HANDOFF.md og NOTES-PLAN.md er. De to er
arbejdspapirer om *dette* program; den her er beskrivelsen af en del af det, og
den bliver kun forkert, hvis koden flytter sig uden at den følger med — hvilket
er lettere at opdage, når den ligger ved siden af.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:02:59 +02:00
Kristian WindandClaude Opus 5 be581eb8ae fix(search): et ord inde i et ord kunne ikke findes
Release / Build and publish image (push) Failing after 9m57s
Meldt ind som "søgning har ikke det store held": en note med
`gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory".

Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et
ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke
findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står
lige der.

Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk
`keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og
de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af
den, der ikke kan.

Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer,
identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er
ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.

Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et
sted i noten, hvor som helst, i hvilket som helst ord. `fold` er
`title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker
titlen, brødteksten og "gron" for "grøn" på én gang.

Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være
værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i
mine opgaver" er den næste melding, og svaret på den ville være denne linje.

Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med
et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede
noter er det fire megabyte og et par millisekunder, og den kører kun, når den
hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved
at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en
større scanning.

Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene,
som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af
indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille
forsvundet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:33:22 +02:00
Kristian WindandClaude Opus 5 9d34b57166 feat(calendar): ugen er et døgn, og dagene er lige brede
Release / Build and publish image (push) Failing after 9m58s
To ting, meldt ind fra en uge, der ikke svarede på det, man åbnede den for.

**Dagene var ikke lige brede.** Gitteret stod på `repeat(7, 1fr)`, og `1fr` er
`minmax(auto, 1fr)`: `auto` som minimum betyder, at en søjle ikke kan blive
smallere end sit længste ord. Én opgave, der hed "Gårdbutikken på Møllerup Gods —
opfølgning på mail", gjorde onsdag dobbelt så bred som mandag og klemte resten af
ugen sammen om den. `minmax(0, 1fr)` lader søjlen blive så smal, den skal, og så
klipper brikken sin tekst af — hvilket den allerede kunne.

**Ugen var syv punktopstillinger.** Den svarede på "hvad skal der ske torsdag" og
ikke på "hvornår", og det andet er det, man åbner en uge for. To møder klokken ti
er en konflikt, man skal kunne se, ikke læse sig til.

Nu et gitter med en tidsrende og syv dage: timerne ned ad siden, begivenheder
placeret efter klokkeslæt, og et bånd foroven til det, der ikke har et — en
heldagsbegivenhed lagt klokken nul ville påstå, den holdt fra midnat til midnat.

Overlappende ting deler bredden i sammenhængende klynger frem for parvist:
overlapper A med B og B med C, skal alle tre dele, også når A og C ikke rører
hinanden. Ellers ville A og C hver tro, de havde halvdelen, og lægge sig oven på
hver sin halvdel af B.

Hvilke timer der tegnes, kommer fra ugens egne begivenheder med arbejdsdagen som
gulv. Et helt døgn er fireogtyve rækker, hvoraf de otte altid er tomme, og så er
gitteret noget man ruller i frem for noget man ser — men en vagt klokken fem skal
med, uden at alle andre uger betaler for den med tomme rækker.

Måneden er ikke rørt ud over søjlebredden. En dag i en månedsrude er halvanden
centimeter høj; et døgn tegnet i den er ikke en visning, det er en streg.

Brikkerne er flyttet ud i to snippets, som begge visninger tegner med. De viser
de samme to ting og skal vise dem ens; det er placeringen, der er forskellig, og
så er det kun placeringen, der står to steder.

`data-date` følger med over på ugens timesøjler — det er sådan noget uden for
komponenten peger på en dag. Båndet får sit eget `data-allday`, så de to ikke
bliver til det samme svar på ét spørgsmål.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:01:58 +02:00
Kristian WindandClaude Opus 5 4b3abc44fb feat(site): hvad der er landet, og hvad man tager på sig ved at køre det
To dele.

**Lately** siger, hvad der er kommet til, fem linjer med versionsnummer i egen
søjle. En landingsside kan påstå, at et projekt er i live; en liste over hvad der
faktisk landede siger det bedre. Ikke en changelog — den ville gå i stå — så den
peger på release-noterne, som skrives alligevel.

**At your own risk** siger det, MIT-licensen siger, og som ingen læser: uden
garanti af nogen art. Dertil det, der er sandt om netop dette: én persons projekt,
version 0.x, du hoster det selv, så data og sikkerhedskopier er dine — og en
sikkerhedskopi, ingen har gendannet, er ikke en sikkerhedskopi endnu. At der er
lavet to gennemgange af koden, at hvert fund er skrevet ned og lukket, og at
**ingen udefra har auditeret det**.

Sat i samme skriftstørrelse som alt andet på siden. Et forbehold med mindre skrift
end resten er skrevet for at kunne peges på bagefter, og det er den ene ting,
siden ikke skal gøre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:19:44 +02:00
Kristian WindandClaude Opus 5 b87c9ea6e2 fix(auth): en afvist passkey sagde "forkert e-mail eller adgangskode"
Release / Build and publish image (push) Failing after 10m0s
Der er hverken en e-mail eller en adgangskode i et passkey-login. Loginet er
discoverable — enheden ved selv, hvilken konto nøglen hører til, og der bliver
ikke tastet noget. Alligevel fik en afvisning fladens generiske 401-tekst, og den
nævner begge dele.

Det sendte en person ud at lede efter en tastefejl i sin konto, der aldrig havde
været der. Det, der faktisk var sket, var at instansen var flyttet fra http til
https: en passkey hører til præcis den adresse, den blev lavet på — `RPOrigins`
bygges af `VERDANDE_BASE_URL` med skema og alt — så et login på det ene skema mod
en server, der venter det andet, kan ikke verificeres.

Beskeden siger nu det, og siger også hvor man skal kigge. Serveren har hele tiden
svaret "that key was not accepted"; det var oversættelsen i fladen, der gjorde
den til noget andet.

Aflysning bliver behandlet som før — den er ikke en fejl, og en fejl for den ville
læse som om nøglen blev afvist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:59:34 +02:00
Kristian WindandClaude Opus 5 ce1d27f8ca fix(notes): én note, der ikke kunne tegnes, låste hele ruden
Release / Build and publish image (push) Failing after 9m53s
Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker på,
åbner de ikke".

Editoren indlæste med `note.body.trim()` uden værn. `body` er en streng fra
serveren hver eneste gang — indtil den ikke er, og så kaster den her inde i et
`$effect`. En effekt, der kaster, tager komponentens opdateringer med sig: den
forrige note bliver stående, og *ingen* note kan åbnes bagefter. Klikket kan ikke
engang lande, hvilket er derfor det ikke ligner en fejl i noten, man klikkede på,
men i hele ruden.

To ting, ikke én. Værnet, så en manglende krop er en tom note. Og en ramme om
tegningen, så det næste, der en dag ikke kan tegnes, heller ikke bliver til det
her: en note, der fejler, viser sin egen tekst uden formatering, og resten af
ruden virker videre.

`loadedId` sættes nu før tegningen frem for efter. Ellers ville effekten prøve
igen ved hver eneste ændring bagefter og kaste hver gang.

`textContent` og ikke `innerHTML` i faldbakken: teksten er ikke tegnet, og at
lægge den ind som markup ville være at køre præcis det, tegningen nægtede.
Gemningen er ikke i fare — den skriver `draft`, som kommer fra svaret og ikke fra
det, der står i editoren, indtil nogen taster i den.

Prøven tvinger formen frem i stedet for at vente på den nyttelast, der gjorde
det. Hvad der end fik `body` til at mangle, er svaret det samme: én note må ikke
kunne låse de tolv hundrede andre ude. Den er set fejle uden rettelsen — med
netop den fejl, konsollen viste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:32:36 +02:00
Kristian WindandClaude Opus 5 cab4e703f5 fix(sikkerhed): CalDAV lod en viewer skrive, og Web Push kunne ringe indad
Release / Build and publish image (push) Failing after 10m0s
To huller fundet ved en gennemgang efter dagens arbejde. Begge har en prøve, der
er set fejle før rettelsen.

CalDAV PUT afgjorde adgang på det projekt, klienten *nævnte i adressen*, og tog
derefter opgavens id fra den samme adresse. Intet bandt de to sammen. En viewer
på et delt projekt kunne pege på et projekt, de selv ejer — indbakken, som alle
har og alle må redigere — og lægge den delte opgaves id i filnavnet. Projekt-
tjekket gik godt på indbakken, og skrivningen landede på en opgave, de kun må
læse. GetTask lukkede det ikke: den svarer "må denne person se det", og det må en
viewer. `CanEdit` er spørgsmålet, og det er det samme, handleUpdateTask altid har
stillet — API-døren har spurgt hele tiden, CalDAV-døren spurgte ikke.

Opgaven skal nu også ligge, hvor adressen siger. Uden det kan en redaktør på to
projekter nå det ene gennem det andets adresse; intet eskaleres af det i sig
selv, men en adresse, der har lov at lyve om hvor noget bor, er sådan sagen
ovenfor kom ind.

Web Push blev leveret med en almindelig http.Client. Adressen kommer fra
browserens abonnement — altså fra den, der er logget ind — og serveren POSTer til
den på et skema, de selv vælger: sæt en påmindelse et minut ude, og jobbet går
igen hvert minut. Tilmeldingen afviser en privat adresse, og den kontrol er ikke
nok alene: den kører på URL'en som skrevet, og et navn, den godkender, kan svare
127.0.0.1 næste gang det slås op. Det er præcis den sætning, `internal/safedial`
selv står og advarer om. Leveringen går gennem safedial nu, som tjekker den
opslåede adresse ved forbindelsen og hvert viderestillingshop på samme måde.
Kontrollen ved tilmelding bliver, fordi den siger nej, mens nogen kigger på
skærmen.

Og en hærdning: /inbound/mail har fået et loft. Det er den svagere af de to
offentlige skriveveje — beaconet skriver en række for en fremmed, denne skriver
ingenting uden en 256-bit adresse, man skal kende i forvejen — men adressen deles
med mailservere og står på en indstillingsside, og når den først er kendt, var der
intet loft på, hvad der kunne laves med den. Tallet er sat generøst: modtageren er
mailserveren, én adresse der leverer det hele.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:15:28 +02:00
188 changed files with 17791 additions and 971 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.)
+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/
+437
View File
@@ -0,0 +1,437 @@
# Notes, as verdande builds them
A description of one working notes system: the data model, the syntax, the search,
the rendering contract, the import and export formats, and — more useful than any
of it — the decisions that look arbitrary until you know what they cost.
It is written to be lifted into another program. Nothing here depends on Go,
SQLite or Svelte except where it says so; where an answer was forced by the tool,
that is called out so you can pick a different one on purpose rather than by
accident.
---
## Read this part first: what to ask before you build
**This document is not a drop-in. It describes a notes system that lives inside a
task app with projects, roles, a trash and a search box, and about a third of its
design is inherited from that.** Lifting it into a program with different
surroundings without deciding which parts to keep produces something that works
for a week and then fights you.
**If you are an assistant working from this file: ask these questions and get
answers before writing code.** Do not guess a default. Each one changes what you
build, not just how you build it.
### About the surroundings
1. **Does the host app already have a place to file things** — projects, folders,
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
filed them under the projects that already existed. If your app has nothing,
you need an answer for where a note lives, and "a folder tree" is the expensive
one.
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
through the whole model below. Single-user changes the schema and removes about
half the permission surface.
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
it, deleting is either permanent or you are building a trash.
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
ranking section below changes accordingly.
### About the notes themselves
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
That decision is defended below; it is also the one that most constrains
everything else.
6. **Is the first line the title, or is there a title field?** Both work. Deriving
it means one less thing to keep in step and one less thing a person can leave
blank, and it forces the rule "the first line is a heading" on the editor.
7. **What links to what?** `#project`, `[[note]]` and task references exist here
because the app has projects and tasks. Yours may have none of them, or others.
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
authorisation path and an XSS surface. Skipping them is a legitimate answer.
### About scale and language
9. **How many notes, realistically?** Several choices below flip at around ten
thousand: the list payload, the search fallback, rendering the whole list.
10. **Which languages?** The folding described under *Search* is Danish. Every
language has its own version of that problem and the generic answer (strip
diacritics) is wrong for several of them.
11. **Where does the text end up besides the screen?** Search indexes, AI
features, exports and backups each read it. Plaintext makes all of them cheap
and makes a stolen backup expensive — see *What is still open*.
### About integration, specifically
12. **Who owns the note's identity — you or the other system?** If notes will sync
with something else, ids and timestamps are the whole problem, and the
`updated_at` rule below becomes the first thing you have to change.
13. **Is this a read of somebody else's notes, or the store of record?** A copy
needs conflict rules; a store of record needs export.
14. **What happens to a note whose project, task or link target is deleted?**
Answered here as "the link goes dead and the note is untouched". If your host
app cascades instead, say so before the first delete.
---
## The shape of a note
```
id an opaque string, unique
project_id nullable — where it is filed, or loose
title derived, never set by a caller
body Markdown, the whole note
created_by nullable — survives the account being deleted
pinned favourite; the list puts these first
archived_at nullable timestamp — put away, not thrown away
deleted_at nullable timestamp — the trash
created_at
updated_at
```
### Markdown in a column
Not rich text, and not a format of our own.
Export was a requirement before a line was written, and **if the stored form is
the exported form there is no conversion left to lose anything in.** A note stays
readable by any editor on the day the program is gone. It is also what makes
search and anything an AI does over notes cheap: both read text.
The cost is real and you should know it before choosing: everything the editor can
do has to survive a round trip through Markdown, and anything Markdown cannot say,
the editor must not offer. There is no place to hide a feature.
### The title is the first line
Always, and re-derived on every save — not "when it is empty".
Derived once and then left alone, a title goes stale the moment somebody rewrites
the opening of a note, and the list ends up calling a note by a name that is
nowhere in it. **The column is a cache of the body, not a field beside it**, and no
caller can set it, so every caller gets the same answer.
This forces one editor rule: a new note opens on its title, and the first line is
styled as a heading. Otherwise a note whose first line is a sentence is a note
named by its first sentence.
### Archive and trash are two different bits
The trash says *this was a mistake, and in thirty days it is gone*. The archive
says *this is finished, and I do not want to read past it.*
A twelve-hundred-note import is exactly the case where most notes are worth
keeping and almost none are worth looking at. Without an archive, the list is the
whole archive forever, and finding this week's note means reading past a decade.
Both are nullable timestamps rather than booleans: *when was this put away* answers
questions a flag cannot, and it costs the same byte.
---
## Filing: no hierarchy of its own
A note belongs to a project, or to nobody.
The reasoning is worth repeating because it is easy to talk yourself out of:
projects already exist, are already shared, already have roles, already have a
trash. **A second hierarchy to file things in is one too many**, and a note filed
under a project inherits every one of those answers instead of needing its own.
- A note with a project: everybody who can read the project can read the note; an
editor can change it.
- A note without one: the author's, and nobody else's.
Sharing a note *is* filing it in a project. There is no access list of its own —
a second way to give somebody access is a second place to get it wrong, and the
two would disagree the day one of them changed.
---
## Links: three kinds, parsed out of the text
The body is the truth. Links are an index over it, rebuilt on every save.
| Syntax | Means | Notes |
|---|---|---|
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
Patterns as used here:
```
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
note \[\[([^\]\n]{1,200})\]\]
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
```
Three decisions inside that table:
- **`#Firma` in a note means the same project as `#Firma` in the task box.** Not a
second kind of tag that happens to look alike. That is the whole of "tag a note
and see it in the project".
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
and the set is small; a diff is a second description of the same fact, and it
can disagree with the text. That is the one thing this must never do.
- **The target is not a foreign key.** A link to something deleted is a fact about
the note. It should read as a dead link rather than vanish, and it must not
block the delete.
The index exists so the question can be asked **backwards**: *what has been written
about this task?* Answering that by searching every note's prose would be too slow
to leave a panel open on.
---
## Search
Three layers, in the order they answer.
### 1. Full-text, ranked
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
Ranking is bm25 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.
This matters more than it sounds. Ordering by "last touched" is invisible with ten
notes and useless with twelve hundred imported the same evening: searching "Tesla"
answered with a note about nginx first and the note actually called "Tesla Model Y"
second — which reads as a search that does not work, because from where the person
is sitting it is one.
### 2. Folding, for the language you are in
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
typing "gron", which is how people type when the keyboard is not theirs.
The answer here is a generated column carrying a transliterated copy:
```
ø,Ø → o æ,Æ → ae å,Å → aa
```
indexed alongside the body and **weighted the same as the body** — otherwise a
folded match outranks an exact one.
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
> naive lowercasing entirely. Get the mapping from somebody who types the
> language.
### 3. A word inside a word
An index is built of tokens, and a token is whatever the tokenizer decided was a
word. `gamerule keepInventory true` is three of them, and the middle one is
`keepinventory` — so a search for "keep inventory" asks for two words that are
nowhere and gets nothing, while the note sits right there.
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
are ANDed, so the half that works is thrown away by the half that cannot.
**This is not exotic in a notes app.** A decade of notes is full of commands,
identifiers and file names, and every one of them is several words the index
thinks is one.
So: the index answers first; when it has nothing, the text is read, and every term
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
column covers title, body and language folding at once.
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
notes that is a few megabytes and a millisecond or two, and it only runs when the
fast path already came back empty — which is exactly the search that was about to
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
answer then is a trigram index, not a bigger scan.
It does not rank. There is no match to rank — that is the whole situation.
---
## The list, and why the body is empty in it
A list showing a title and ninety characters of preview must not fetch whole
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
see.
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
A field called `body` holding a *piece* of the body is a trap. The editor opens
it, the autosave pause writes it back, and the note is now cut down to its own
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
its own field and leave `body` empty; the client sees the empty body and fetches
the whole note.
That costs one more separation, and it is not optional:
> **Which row is selected and which note is loaded are two different things.**
The editor loads its text when the note's id changes. Hand it the excerpt first
and the whole note second and the id is the same both times — so the second one
never renders, and somebody sits looking at a note that had a title a moment ago.
Highlight the row immediately from the list; give the editor the note only when it
is whole.
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
virtual list, no scroll maths, and no list that can fall out of step with itself.
Try that before building windowing.
---
## The editor contract
Rich text on top, Markdown underneath, and a source view that shows the file.
The supported subset — deliberately small, because everything in it has to survive
the round trip:
| Block | Markdown |
|---|---|
| Title | `# ` |
| Heading | `## ` |
| Subheading | `### ` |
| Quote | `> ` |
| Code block | ```` ``` ```` with an optional language |
| Bullet and numbered lists | `- `, `1. `, nested |
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
and images as `![alt](url)`.
Five rules that were each learned the hard way:
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
lines into a paragraph. Here it must not: the person typed a line and expects
to get it back, and an editor that silently reflows what somebody wrote is an
editor they stop trusting.
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
the caret back at the start, which mid-sentence is unusable.
3. **One definition of what a code block contains.** A copy button inside the
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
the code and the next save put it on disk. Have one function that says what a
block holds, used by the highlighter and the Markdown serialiser both.
4. **A note that cannot be rendered must not take the pane with it.** One note
arriving in an unexpected shape threw inside the editor's reactive effect, and
from then on *no* note would open — the previous one just stayed on screen. Put
a guard around the render, fall back to showing the raw text, and let the rest
of the pane keep working.
5. **Escaping between tags and escaping inside an attribute are different jobs.**
An escaper written for text between two tags handles `&`, `<`, `>` — and not
quotes, because between tags they are harmless. Inside an attribute they close
it. `![" onerror="alert(1)](…)` is the whole exploit.
---
## Attachments
Images pasted or dragged into a note, stored content-addressed by hash, referenced
from the body as ordinary Markdown images.
The security shape, which is the part worth copying:
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
are the one place a user supplies content another user opens. An uploaded SVG or
HTML file rendered inline runs its own script on your origin with the session
cookie attached.
- **One exception, as a named allowlist:** a short list of raster image types
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
A note carries its pictures in the text, and served as octet-stream every one of
them is a broken image.
- **The list is named one type at a time, never matched on an `image/` prefix.**
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
script.
- **The stored path is checked against the files directory again at read time.** A
traversal that somehow got into the database must not become one that reads
`/etc/passwd`.
- **Reaching an attachment follows the note, which follows its project.** Same
chain as everything else. Do not let a client hand you the parent id and take it
on trust.
---
## Import and export
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
front matter block.
```markdown
---
created: 2019-04-02T10:15:00Z
modified: 2024-11-30T21:04:00Z
---
# Møde med Anders
Han vil gerne have kaffe hver uge.
```
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
all accepted on the way in. Obsidian and Bear write this block; so should you.
- Images travel in the zip beside the notes, and the links in the text are
relative — **a note and its pictures must end up in the same archive**, which
matters the moment an export is split into parts.
- File names collide: two notes called "Møde" are two notes, and a zip with one
entry twice is a zip that loses one.
Limits that a real import taught, all of them cheap to add and expensive to omit:
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
64 MB of zip expanded to 66 GB is a normal shape for an attack.
- A per-file limit as well as a whole-archive budget: one enormous photograph and
ten thousand small ones are different attacks with the same ending.
- The entry name is never used as a path. Store by hash; the name is only a key
for rewriting links.
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
dropped silently by a filter looking for `..` — one out of 1141, found only by
counting at both ends.
### Timestamps on the way in
An importer needs to write `created_at` and `updated_at` from the file, and the
normal save path must refuse to let it.
`updated_at` means *when did this program last write this note*. Let any caller set
it and the column means whatever the last writer felt like. Give the importer its
own narrow entry point instead, and say in the comment why it exists.
Without those dates, a move of twelve hundred 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.
---
## What is still open here
Written down because a specification that only lists what works is a specification
that lies by omission.
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
they are in every backup anybody can download. Encrypting them is bigger than it
sounds: the body is in the full-text index and the folded column is generated
from it, so both are a second plaintext copy in the same database. Encrypt and
both have to go, and search has to be rewritten to decrypt in application code —
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
point the answer is a keyed index (an HMAC per word).
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
- **There is no sync.** Export and the API are the way out.
---
## A short list of the traps, for the person in a hurry
1. A `body` field holding an excerpt will be saved over the real one.
2. Selected row and loaded note are two different pieces of state.
3. The title must be re-derived on every save or it goes stale.
4. Diacritic stripping is not folding, and folding is language-specific.
5. A tokenizer cannot see a word inside a word; decide what you do about it.
6. An attachment served inline is a script on your origin unless you allowlist by
exact type and send `nosniff`.
7. Escaping for text and escaping for attributes are different functions.
8. One render failure must not be allowed to freeze the whole pane.
9. An unpacked-size limit is not optional on an archive import.
10. Let the importer set timestamps; let nobody else.
+14 -7
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
@@ -102,17 +103,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.
+27
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,7 @@ func run() error {
runner.SyncCalendars = api.SyncCalendars
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.Start(jobCtx)
srv := &http.Server{
+2 -2
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
@@ -108,7 +108,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. |
+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.
+20 -1
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 |
+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
+41 -11
View File
@@ -11,28 +11,46 @@ 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,
and a task shows what has been written about it.
The calendar draws each task to its length — drag the foot to resize, the body
to move it.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
single note with a person, 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.
- **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.
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
out as Web Push to an iPhone or a Mac.
- **[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 +66,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)
+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
+163
View File
@@ -0,0 +1,163 @@
# 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.
### 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.
## 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.
+681 -6
View File
@@ -805,14 +805,36 @@ paths:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/{taskID}/snooze:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
post:
tags: [tasks]
summary: Snooze a task until a time, or wake it.
description: |
Parks the task at the bottom of its list, greyed, until `until` passes —
nothing else about it moves. An empty `until` wakes it now.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
responses:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/tasks/quick-add:
post:
tags: [tasks]
summary: Create a task from one line of natural language.
description: |
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
vigtigt. An unknown `#project` is not an error — the task goes to the
Danish and English. `file the VAT return tomorrow at 10am p1 #Accounts @tax`
becomes a task due tomorrow at 10:00, priority 1, in the project Accounts,
labelled tax. An unknown `#project` is not an error — the task goes to the
Inbox rather than a thought being thrown away over a typo.
requestBody:
required: true
@@ -1228,7 +1250,7 @@ paths:
The one click that opens Google's consent screen already exists. What it
needs behind it is a registered client, and no app can conjure one: Google
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
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 that used it.
@@ -2079,6 +2101,95 @@ paths:
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/calendar/subscriptions:
post:
tags: [calendar]
summary: Subscribe to a calendar you only have an address to.
description: |
An ICS or `webcal://` address. Several per person, unlike the Google
connection, which is one — the address is the identity here, the way the
token is for OAuth.
The address is fetched once before it is stored: an address that cannot be
read is a typo far more often than a server that happens to be down, and
saying so while somebody is still looking at the field is worth two
seconds. `webcal://` is rewritten to `https://`, and the destination is
checked against the private-network wall both when it is written down and
again on the resolved address every time it is fetched.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [url]
properties:
url: { type: string, description: "https:// or webcal://" }
name:
type: string
description: Optional. The calendar's own name is used when this is empty.
responses:
"201":
description: Subscribed, and fetched once.
"422": { $ref: "#/components/responses/Validation" }
/api/v1/calendar/subscriptions/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
delete:
tags: [calendar]
summary: Stop subscribing. The cached events go with it.
responses:
"204": { description: Gone. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/push/test:
post:
tags: [notifications]
summary: Send one notification to your own devices, and say what happened.
description: |
The answer to "no notifications arrive". A push that a service refuses is
the ordinary failure — a stale subscription, a browser that revoked
permission without telling the server — and every one of them looks
exactly like silence from the outside.
It goes through the same path a real notification takes, so what it proves
is the thing that has to work. The title and body come from the caller
because the dictionaries live in the interface.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
title: { type: string, maxLength: 200 }
body: { type: string, maxLength: 500 }
responses:
"200":
description: What each device did with it.
content:
application/json:
schema:
type: object
properties:
subscriptions:
type: integer
description: Devices registered for this account.
sent:
type: integer
failed:
type: array
items:
type: object
properties:
service:
type: string
description: The push service's host — never the endpoint, which is a credential.
reason:
type: string
description: What the service said.
/api/v1/sections/{sectionID}:
parameters:
- { name: sectionID, in: path, required: true, schema: { type: string } }
@@ -2254,7 +2365,12 @@ paths:
tags: [views]
summary: The next few days, empty ones included.
parameters:
- { name: days, in: query, schema: { type: integer, default: 7 } }
- {
name: days,
in: query,
description: "How many days to return, counting today. Above the maximum is capped, not refused.",
schema: { type: integer, default: 7, minimum: 1, maximum: 31 },
}
responses:
"200":
description: OK
@@ -2602,6 +2718,25 @@ paths:
responses:
"200": { $ref: "#/components/responses/MailAddress" }
/api/v1/hook-url:
get:
tags: [integrations]
summary: Your personal address for pushing a task in from somewhere else.
description: >
Minted on first ask. Its own token rather than the mail one: the two are
the same kind of secret, but they leak separately — 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.
responses:
"200": { $ref: "#/components/responses/HookURL" }
/api/v1/hook-url/rotate:
post:
tags: [integrations]
summary: Issue a new push address, retiring the old one at once.
responses:
"200": { $ref: "#/components/responses/HookURL" }
/api/v1/notifications:
get:
tags: [integrations]
@@ -2901,6 +3036,12 @@ paths:
post:
tags: [notes]
summary: Write a note.
description: >
`#Projekt` files the note against a project and `[[Another note]]` links to
one. A third, `@Name`, shares the note with that person as a viewer and
tells them — only the owner's mentions share, and only a name that has just
appeared in the text counts, so a note saved on every typing pause does not
share itself twice.
requestBody:
required: true
content:
@@ -2964,6 +3105,11 @@ paths:
patch:
tags: [notes]
summary: Change a note. Every field is optional; what is sent is what changes.
description: >
A name written with an `@` that was not in the text before shares the note
with that person as a viewer and tells them. Only the owner's mentions
share — an editor may write the name, but giving somebody else's note away
is the owner's alone — and a role already granted is never lowered by one.
requestBody:
required: true
content:
@@ -2983,6 +3129,147 @@ paths:
"204": { description: Deleted }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/{noteID}/shares:
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
get:
tags: [notes]
summary: Who a note is shared with, and who it can be shared with.
description: >
Owner only. Returns the people the note is already shared with, each with
their role, and the candidates the owner may add — everyone on the instance
who is not already on the note.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
shares:
type: array
items: { $ref: "#/components/schemas/NoteShare" }
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
invites:
description: >
Addresses invited to the note that have not been taken up yet.
There is no account behind them, so there is no name or colour
to show — the address is all there is until they arrive.
type: array
items: { $ref: "#/components/schemas/NoteInvite" }
follows:
description: >
The notes this one points at with [[links]], which are shared
alongside it. Owner's own notes only.
type: array
items: { $ref: "#/components/schemas/NoteFollow" }
"404": { $ref: "#/components/responses/NotFound" }
post:
tags: [notes]
summary: Share a note with a person, or change the role they hold.
description: >
The person is named either by `user_id` — somebody picked from the
candidates — or by `email`. An address that already has an account is
shared with straight away; one that does not gets an invitation, which
becomes the share the moment the account is created (201).
The notes this one points at with [[links]] are shared with the same person
and the same role, so a link in a shared note leads somewhere. Only notes
the sharer owns follow, and the response lists them.
requestBody:
required: true
content:
application/json:
schema:
type: object
description: One of user_id or email.
properties:
user_id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [viewer, editor] }
responses:
"200":
description: Shared
content:
application/json:
schema:
type: object
properties:
user:
description: >
Who it was shared with. Sent back because a caller that shared
by address does not otherwise know who it reached.
$ref: "#/components/schemas/Person"
role: { type: string, enum: [viewer, editor] }
follows:
type: array
items: { $ref: "#/components/schemas/NoteFollow" }
"201":
description: >
Invited. The address has no account yet, so a link that creates one and
the share together has been issued.
content:
application/json:
schema:
type: object
properties:
invited: { $ref: "#/components/schemas/NoteInvite" }
link:
description: The signup link, shown so it can be passed on.
type: string
emailed:
description: >
False when the instance has no mail configured, which is when
the link is the only way the invitation reaches anybody.
type: boolean
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/notes/{noteID}/invites/{inviteID}:
delete:
tags: [notes]
summary: Withdraw an invitation to a note before it is used.
description: >
The only way back. Only the token's hash is stored, so a link sent to the
wrong address cannot be looked up and invalidated — only the row it belongs
to can.
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
- name: inviteID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Withdrawn }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/{noteID}/shares/{userID}:
delete:
tags: [notes]
summary: Take a person's access to a note away again.
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
- name: userID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Unshared }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/linking/{kind}/{targetID}:
get:
tags: [notes]
@@ -3163,6 +3450,233 @@ paths:
$ref: "#/components/responses/Conflict"
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/inbox/tidy:
post:
tags: [integrations]
summary: Suggest how the loose tasks in the inbox could have been written.
description: |
Suggests, never writes. Each suggestion is the line the person would have
typed — quick-add syntax, the same one parser as everywhere else — so it
can be read and edited by whoever it is put to, rather than being a set of
fields a model filled in that you take or leave.
The model is given the projects that exist and may name no other: a
`#Project` it invented would create one by accident the first time
somebody said yes.
responses:
"200":
description: The suggestions, one per task, and none for a task already filed well.
content:
application/json:
schema:
type: object
properties:
suggestions:
type: array
items: { $ref: "#/components/schemas/AISuggestion" }
"409": { $ref: "#/components/responses/Conflict" }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/inbox/tidy/apply:
post:
tags: [integrations]
summary: Write one accepted suggestion into the task it is about.
description: |
The line is parsed here rather than in the browser, so "tomorrow" means
what it means everywhere else — and a suggestion edited by hand before it
was accepted is read exactly like one that was not.
Only what the line actually says is applied. A missing date means "the
line said nothing about a date", never "clear the one that is there". A
project the model invented moves nothing.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [task_id, line]
properties:
task_id: { type: string }
line: { type: string }
responses:
"200":
description: The task as it now stands.
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/ai/notes/{noteID}/actions:
parameters:
- { name: noteID, in: path, required: true, schema: { type: string } }
post:
tags: [integrations]
summary: Pull what somebody committed to out of a note.
description: |
Only things somebody committed to or was asked to do — a heading is not a
task, a topic discussed is not a task. An empty array is a useful answer
and a made-up list is not.
Each suggestion quotes the words it came from, so a wrong one can be seen
through rather than merely being wrong.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
suggestions:
type: array
items: { $ref: "#/components/schemas/AISuggestion" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/notes/{noteID}/actions/apply:
parameters:
- { name: noteID, in: path, required: true, schema: { type: string } }
post:
tags: [integrations]
summary: Create one accepted task from a note.
description: |
The note is not touched. Writing the link back into it would make the two
point at each other, and a text a program edits without being asked is a
text people stop trusting — so the task carries where it came from, as a
`[[note title]]` in its description, and the note is left alone.
The note's own project is the default; a `#Project` in the line wins,
because typing it is a choice and inheriting is a circumstance.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [line]
properties:
line: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/ai/plan:
get:
tags: [integrations]
summary: Whether a morning plan is sent, and at what hour.
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
put:
tags: [integrations]
summary: Turn the morning plan on or off, or move the hour.
description: |
The hour is local to the person. Moving it clears the "already sent today"
mark, so somebody who moves the plan from 07 to 16 gets it this afternoon
rather than tomorrow — a setting whose effect you have to wait a day to
see is a setting nobody dares turn on.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
hour: { type: integer, minimum: 0, maximum: 23 }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/DailyPlanSettings" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/ai/plan/now:
post:
tags: [integrations]
summary: Send today's plan now, to whoever asks.
description: |
The counts come from the database and the sentence from the model, in that
order: a model that counted for itself would sometimes count wrong, and 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. A morning with nothing to
do is a good morning, and a notification saying "there is nothing" teaches
people to stop opening notifications.
responses:
"200":
description: The plan, and whether it was sent.
content:
application/json:
schema:
type: object
properties:
plan: { type: string }
sent: { type: boolean }
/api/v1/ai/ask:
post:
tags: [integrations]
summary: Ask a question of your own notes and tasks.
description: |
Search finds what contains the words. This answers the question — "what
did I promise Anders in August?" — which is not the same: the answer is
spread over three notes and a task, and none of them contains the word
"promise".
Two halves, and the order is the whole of the safety in it. The database
finds the candidates with the search that already exists and that already
only sees what this person may see. The model is given those and nothing
else — it does not look anything up and cannot ask for more.
The answer carries what it rests on. A model that says "you did, on the
14th" is only useful if you can open the note it read that in.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [question]
properties:
question: { type: string }
responses:
"200":
description: |
The answer, and the passages it rests on — only those, not everything
that was looked at. An empty answer means nothing matched.
content:
application/json:
schema:
type: object
properties:
answer: { type: string }
sources:
type: array
items:
type: object
properties:
kind: { type: string, enum: [note, task] }
id: { type: string }
title: { type: string }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/mcp:
post:
tags: [integrations]
@@ -3484,6 +3998,69 @@ paths:
task_id: { type: string }
"404": { description: "No such address. Identical to a malformed one, deliberately." }
/inbound/hook/{token}:
post:
tags: [integrations]
summary: Push a task into somebody's inbox from another program.
description: |
The token in the path is the whole credential — the caller is a shortcut on
a phone or a script, and neither can sign in. A token here can do one
thing: create a task in that person's inbox. An API token can do everything,
which is why this is not one.
The body is read three ways, because those are the three you meet: JSON from
a service, a form field from a webhook form, and plain text from a shortcut
that just sends what was selected. The first line becomes the task and the
rest sits underneath it, and the line goes through the quick-add parser — so
"Ring til Anders i morgen p1 #Firma" means here what it means in the box at
the top of the app.
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.
security: []
parameters:
- name: token
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
text/plain:
schema:
type: string
description: First line the task, the rest its description.
application/json:
schema:
type: object
properties:
text: { type: string }
title: { description: Same as text. }
content: { description: Same as text. }
note: { type: string }
body: { description: Same as note. }
description: { description: Same as note. }
application/x-www-form-urlencoded:
schema:
type: object
properties:
text: { type: string }
note: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
task_id: { type: string }
content: { type: string }
"404": { description: "No such address. Identical to a malformed one, deliberately." }
"422": { $ref: "#/components/responses/Validation" }
"429": { description: Too many pushes; the address is rate limited. }
/oauth/gmail/callback:
get:
tags: [integrations]
@@ -3750,6 +4327,15 @@ components:
type: array
items: { type: string }
HookURL:
description: The address another program pushes to.
content:
application/json:
schema:
type: object
properties:
url: { type: string }
schemas:
Calendar:
type: object
@@ -3818,6 +4404,14 @@ components:
pinned: { type: boolean }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
shared_with_me:
type: boolean
description: >
True when the reader reached this note through a direct share rather
than by owning it or its project. What puts it in the Delt med mig group.
owner:
allOf: [{ $ref: "#/components/schemas/Person" }]
description: Who the note belongs to, present on a note shared with the reader.
links:
type: array
description: What the body points at, read out of the text on the way in.
@@ -3834,6 +4428,77 @@ components:
body: { type: string, description: "Markdown. Its first line becomes the title." }
pinned: { type: boolean }
Person:
type: object
description: A user, as much of one as a name and a colour.
properties:
id: { type: string }
name: { type: string }
avatar_color: { type: string }
NoteShare:
type: object
description: One person's direct grant on a note.
properties:
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [viewer, editor] }
linked:
description: >
True when the person has this note because another shared note points
at it, rather than because it was shared with them on its own. Such a
grant is taken back by removing the link or the share it came from, not
by removing it here.
type: boolean
via:
description: The title of the shared note that points at this one.
type: string
NoteFollow:
type: object
description: A note that is shared alongside another because that one links to it.
properties:
note_id: { type: string }
title: { type: string }
DailyPlanSettings:
type: object
description: >
One person's morning plan. Off by default — a notification at seven in the
morning that nobody asked for is not a help.
properties:
enabled: { type: boolean }
hour:
type: integer
description: Local to the person, because seven o'clock is seven different places.
last_sent:
type: string
description: The date it last went out, in that person's own timezone.
AISuggestion:
type: object
description: >
One proposed line, in quick-add syntax, plus one sentence on why it looks
like that. Nothing has happened yet — a suggestion becomes something when
somebody says yes to it.
properties:
task_id:
type: string
description: >
The task the suggestion is about, when it is about one that already
exists. Absent when the suggestion is a new task.
line: { type: string, example: "Ring til Anders i morgen p1 #Firma" }
why: { type: string }
NoteInvite:
type: object
description: >
A share on a note that is waiting for an account. The address is the whole
of who the person is until they sign up.
properties:
id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [viewer, editor] }
Mailbox:
type: object
description: One connected mailbox. The password and any token are never returned.
@@ -4322,12 +4987,22 @@ components:
Notification:
type: object
description: >
Something somebody else did that you need to know about. `kind` says what
happened and the client composes the sentence from it, so the bell follows
the reader's language; `title` is the server's own Danish wording, used for
Web Push, which has no dictionary to look words up in.
properties:
id: { type: string }
actor_name: { type: string }
project_id: { type: string }
task_id: { type: string }
kind: { type: string }
note_id:
description: Set on a notification about a note — a shared note somebody else edited.
type: string
kind:
type: string
enum: [assigned, note.changed, comment]
title: { type: string }
body: { type: string }
read: { type: boolean }
+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.
+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)
}
}
+327
View File
@@ -0,0 +1,327 @@
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"`
}
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
}
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,
})
}
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
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)
}
// handlePlanNow sender 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.
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
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
}
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true})
}
// 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,
})
}
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, ", ")
}
+96
View File
@@ -0,0 +1,96 @@
package httpapi
import (
"net/http"
"testing"
)
// 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)
}
}
+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)
+27
View File
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
if getErr == nil {
// The task decides, not the URL.
//
// The check above is about the project the *client named*, and a client
// names both halves of this address. Asking the project for permission and
// then taking the task id from the same string ties nothing together: a
// viewer on a shared project could name a project of their own — the Inbox,
// which everybody owns and may edit — and put the shared task's id in the
// filename. The project check passed on the Inbox and the write landed on a
// task they may only read.
//
// GetTask does not close it either: it answers "may this person see it",
// and a viewer may. `CanEdit` is the question, and it is the same one
// handleUpdateTask asks — the API door has always asked it.
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// And it has to live where the address says it does. Without this an editor
// on two projects can reach either through the other's URL — nothing is
// escalated by that on its own, but a URL allowed to lie about where a
// resource lives is how the case above got in.
if existing.ProjectID != projectID {
http.NotFound(w, r)
return
}
// An update. Only the fields a task client can express are touched;
// everything else the task carries stays as it is.
content := parsed.Summary
+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
}
+364
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 -----------------------------------------------------------------------------
@@ -250,6 +255,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
return token
}
// The promise of sharing has to hold at every door, not just the API's.
//
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
// authorised differently: it checked the caller's standing in the project *named
// in the URL* and then took the task id from the URL as well. Nothing tied the
// two together.
//
// So a viewer on somebody else's project could name a project of their own —
// their Inbox, which everybody owns and everybody may edit — and put the other
// project's task id in the filename. The project check passed on the Inbox, and
// the write landed on a task in a project they may only read.
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "viewer@example.dk", "Viewer")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "en opgave", "project_id": projectID,
})
taskID := task["id"].(string)
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "viewer@example.dk", "role": "viewer",
}); resp.StatusCode != http.StatusOK {
t.Fatalf("invite: %d %v", resp.StatusCode, body)
}
them, err := ts.db.UserByEmail(t.Context(), "viewer@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, them.ID)
// Their own Inbox: a project they own and may edit, and the one every account
// has. It is what makes the URL check pass.
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
if err != nil {
t.Fatal(err)
}
var inbox string
for _, p := range projects {
if p.IsInbox {
inbox = p.ID
}
}
if inbox == "" {
t.Fatal("the viewer has no inbox")
}
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
token, them.Email, vtodo)
if resp.StatusCode < 400 {
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
}
// And the task is as its owner left it. The status alone is not the assertion:
// the question is whether anything moved.
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if after["content"] != "en opgave" {
t.Errorf("the task was renamed by a viewer: %v", after["content"])
}
if after["completed"] != false {
t.Error("the task was completed by a viewer")
}
}
// A task may only be addressed under the project it is actually in.
//
// The same hole seen from the other side: an editor on two projects could reach a
// task in one of them through the other's URL. Nothing is escalated by it — they
// may edit both — but it is the check that was missing, and a URL that lies about
// where a resource lives is how the viewer case got in.
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "hører til Et", "project_id": one["id"].(string),
})
taskID := task["id"].(string)
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
token, user.Email, vtodo)
if resp.StatusCode < 400 {
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if after["content"] != "hører til Et" {
t.Errorf("the task changed: %v", after["content"])
}
}
func TestCalDAVRequiresCredentials(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
@@ -604,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)
@@ -1358,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")
}
}
+251 -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,284 @@ 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,
})
}
// 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))
}
}
+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)
}
}
+100 -2
View File
@@ -63,6 +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
@@ -85,6 +89,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
hub: realtime.NewHub(log),
loginLimiter: newLimiter(10, 15*time.Minute),
// Generous on purpose: the caller is the mail server, one address delivering
// everything, and a personal instance gets a handful of forwarded mails a
// 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
@@ -102,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))
@@ -122,7 +135,29 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Inbound mail. Delivered by the mail server rather than by a browser, and
// authenticated by the token in the recipient address.
r.Post("/inbound/mail", s.handleInboundMail)
//
// Rate limited for the same reason the beacon is: it is reachable by anybody
// who can route to this instance. It is the weaker case of the two — the
// beacon writes a row for a stranger, this one writes nothing without a
// 256-bit address it has to already know — but an address is shared with mail
// servers and printed on a settings page, and once it is known there was no
// ceiling at all on what could be made with it.
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
@@ -281,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) {
@@ -297,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) {
@@ -311,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)
@@ -326,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)
@@ -375,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) {
@@ -518,6 +590,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)
})
})
})
@@ -640,6 +713,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))
}
+130
View File
@@ -6,6 +6,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"testing"
"time"
@@ -284,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)
@@ -1777,3 +1821,89 @@ func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
t.Errorf("content = %v", moved["content"])
}
}
// A word inside a word, on the task side.
//
// The notes search grew this first — a note holding `keepInventory` could not be
// found by typing "keep inventory" — and a task is the same sentence with a
// different table behind it. `SpeedAdmin` is one token, so "speed admin" asks for
// two words that are nowhere.
//
// Having it in one of the two searches would be worse than having it in neither:
// "it finds it in my notes but not in my tasks" is the next report.
func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "Opret kodeord til Kulturskolen (SpeedAdmin)",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Køb kaffe"})
for _, tc := range []struct {
query string
want int
}{
{"speed admin", 1},
{"admin", 1},
{"speedadmin", 1},
{"kulturskolen", 1},
{"vandhane", 0},
} {
resp, body := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(tc.query), nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%q: status %d", tc.query, resp.StatusCode)
}
tasks, _ := body["tasks"].([]any)
if len(tasks) != tc.want {
t.Errorf("%q found %d tasks, want %d", tc.query, len(tasks), tc.want)
}
}
}
// 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 {
+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))
}
}
}
+16
View File
@@ -49,6 +49,11 @@ type Runner struct {
// layer, which owns the VAPID keys and the subscription list.
Push func(userID, title, body, projectID string)
// DailyPlans sender morgenens plan til dem, der har bedt om en. Sat af
// serveren af samme grund som SendBeacon: opsætningen, tidszonen og
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
DailyPlans func(ctx context.Context) error
// SendBeacon reports this installation to the collector, if it is switched on
// and a day has passed. Supplied by the HTTP layer for the same reason as the
// syncs: that is where the instance settings and the version live.
@@ -89,6 +94,10 @@ func (r *Runner) Start(ctx context.Context) {
// the same reason: a container asleep at the scheduled minute would otherwise
// skip the day. SendBeacon decides whether a day has passed.
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
// Dagens plan. Hver time, sendt højst én gang om dagen pr. person — samme
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
// syv er syv forskellige steder.
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -123,6 +132,13 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
}()
}
func (r *Runner) dailyPlans(ctx context.Context) error {
if r.DailyPlans == nil {
return nil
}
return r.DailyPlans(ctx)
}
// sendBeacon reports that this installation exists, at most once a day.
//
// Every decision about what is sent, and why this is on by default rather than
+20 -1
View File
@@ -38,11 +38,30 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
func (s *Sender) Configured() bool { return s.cfg.Configured() }
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
what := "verdande"
if projectName != "" {
what = "projektet “" + projectName + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
// SendNoteInvite is the same letter about a single note.
//
// The title is in the mail because it is what makes the invitation mean anything —
// "Kristian har inviteret dig til noten “Aftale om levering”" is a sentence
// somebody can decide about, where "til en note" is one they have to click a link
// to understand. It travels no further than the address the owner typed, which is
// the address they chose to share the note with.
func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle, link string, ttl time.Duration) error {
what := "en note"
if noteTitle != "" {
what = "noten “" + noteTitle + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
body := fmt.Sprintf(`Hej
%s har inviteret dig til %s.
+22 -1
View File
@@ -32,6 +32,8 @@ import (
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/safedial"
)
type Subscription struct {
@@ -54,6 +56,13 @@ type Payload struct {
Body string `json:"body,omitempty"`
URL string `json:"url,omitempty"`
Tag string `json:"tag,omitempty"`
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
//
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
// er serverens, og serveren ved det i forvejen.
Unread int `json:"unread,omitempty"`
}
var b64 = base64.RawURLEncoding
@@ -101,7 +110,19 @@ func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) e
req.Header.Set("Urgency", "normal")
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
// safedial, not a plain client. The endpoint comes from the browser's
// subscription, which is to say from whoever is signed in, and this server
// POSTs to it on a schedule they choose — set a reminder a minute out and the
// job goes again every minute.
//
// The subscribe handler already refuses a private address, and that check is
// not enough on its own: it runs on the URL as written, and a name it approves
// can answer 127.0.0.1 the next time it is looked up. safedial checks the
// *resolved* address at connect time, which is the only moment the address
// that will actually be used is known — and it checks every redirect hop the
// same way. The parse-time check stays, because it says no while somebody is
// looking at the screen.
resp, err := safedial.Client(15 * time.Second).Do(req)
if err != nil {
return err
}
+89
View File
@@ -0,0 +1,89 @@
package push
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A subscription must not be able to point the server at its own network.
//
// The endpoint is whatever the browser handed over, which is to say whatever the
// person signed in handed over, and this server POSTs to it on a schedule they
// choose. The subscribe handler refuses a private address when it is written
// down — and that check runs on the URL as written, so a name it approves can
// answer 127.0.0.1 the next time it is looked up. This is the other half: the
// address that will actually be dialled, checked at the moment of dialling.
func TestSendRefusesToReachInside(t *testing.T) {
reached := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
// httptest listens on 127.0.0.1, which is exactly the destination in question.
if !strings.Contains(srv.URL, "127.0.0.1") {
t.Skipf("the test server is not on loopback: %s", srv.URL)
}
err := Send(context.Background(), testSubscription(t, srv.URL), Payload{Title: "hej"}, testVAPID(t))
if err == nil {
t.Fatal("Send reached a loopback address without complaining")
}
if reached {
t.Error("the request arrived: the wall let it through and only the answer failed")
}
}
func testSubscription(t *testing.T, endpoint string) Subscription {
t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
t.Fatal(err)
}
return Subscription{
Endpoint: endpoint,
P256dh: b64.EncodeToString(key.PublicKey().Bytes()),
Auth: b64.EncodeToString(auth),
}
}
func testVAPID(t *testing.T) VAPID {
t.Helper()
pub, priv, err := GenerateVAPIDKeys()
if err != nil {
t.Fatal(err)
}
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
}
// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der
// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et
// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der
// ikke lykkedes.
func TestPayloadCarriesTheUnreadCount(t *testing.T) {
with, err := json.Marshal(Payload{Title: "hej", Unread: 3})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(with), `"unread":3`) {
t.Errorf("payload = %s, want the unread count in it", with)
}
without, err := json.Marshal(Payload{Title: "hej"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(without), "unread") {
t.Errorf("payload = %s, want no count when there is none to send", without)
}
}
+11 -1
View File
@@ -650,8 +650,18 @@ func pad2(n int) string {
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
// all of it and leave the task not repeating at all.
//
// Mellemleddet må være et ord og ikke kun et tal. "hver anden tirsdag" er den måde,
// folk siger det på — recurrence-pakken har oversat "hver anden" til "hver 2."
// siden den blev skrevet — men her stoppede mønsteret efter "anden" og gav den
// sætning videre. Den er ikke en regel, så "hver anden" blev stående i titlen og
// "tirsdag" blev en forfaldsdato. Sløjfen nedenfor kan kun *fjerne* ord fra
// enden; den kan ikke hente et, mønsteret aldrig tog med.
//
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
// ikke var det.
var reRepeat = regexp.MustCompile(
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+|(?:anden|andet|tredje|fjerde|femte|sjette|other)\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
+33
View File
@@ -603,3 +603,36 @@ func TestASectionIsWrittenWithASlash(t *testing.T) {
}
}
}
// "hver anden tirsdag" er den måde, folk siger det på.
//
// recurrence-pakken har oversat "hver anden" til "hver 2." siden den blev skrevet,
// men sætningen nåede aldrig frem til den: mønsteret her tog kun ét ord efter
// "hver", så det, der blev givet videre, var "hver anden" — ikke en regel. Så blev
// de to ord stående i titlen, og "tirsdag" blev til en forfaldsdato. Opgaven
// gentog sig ikke, og der stod ikke noget om det.
//
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
// ikke var det.
func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
for _, tc := range []struct {
text string
rule string
left string
}{
{"vande planterne hver anden tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
{"vande planterne hver 2. tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
{"water the plants every other tuesday", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "water the plants"},
{"gå tur hver tredje søndag", "FREQ=WEEKLY;BYDAY=SU;INTERVAL=3", "gå tur"},
// Den, der virkede i forvejen, skal blive ved med at gøre det.
{"tømme opvasker hver mandag", "FREQ=WEEKLY;BYDAY=MO", "tømme opvasker"},
} {
got := Parse(tc.text, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), "da")
if got.Recurrence != tc.rule {
t.Errorf("%q gav reglen %q, want %q", tc.text, got.Recurrence, tc.rule)
}
if got.Content != tc.left {
t.Errorf("%q efterlod titlen %q, want %q", tc.text, got.Content, tc.left)
}
}
}
+50 -2
View File
@@ -24,17 +24,58 @@
// lives), the private ranges, unique-local IPv6, and the unspecified address.
// Everything else is allowed: this is not an allowlist of the internet, it is a
// wall around the inside.
//
// With one named exception, for Tailscale — see tailnets below.
package safedial
import (
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"syscall"
"time"
)
// tailnets are Tailscale's own ranges, and they are not "the inside".
//
// A tailnet is not a network you happen to be on; it is one every member had to
// authenticate to join. A verdande whose host is on one reaches its peers on
// purpose, and the operator who joined it meant to.
//
// Half of this already worked, by luck rather than intent: Go's IsPrivate covers
// RFC1918 and not the carrier-grade NAT range Tailscale borrows, so 100.x
// addresses passed the wall without anybody deciding they should. The IPv6 half
// did not — fd7a:115c:a1e0::/48 sits inside fc00::/7 and was refused. The same
// machine was reachable or not depending on which family its address had, which
// is the kind of difference nobody debugs on purpose.
//
// Naming both makes the allowance deliberate, and writes down its cost: on an
// instance with people you have invited, this is a network they can now ask the
// server to reach. That is the trade, and it is the operator's to make.
var tailnets = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("fd7a:115c:a1e0::/48"),
}
// IsTailscale reports whether an address belongs to a tailnet.
func IsTailscale(ip net.IP) bool {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
// Unmap first: ParseIP gives a v4 address as 4-in-6, and a v4 prefix does not
// contain a v6-shaped address however equal the two look when printed.
addr = addr.Unmap()
for _, n := range tailnets {
if n.Contains(addr) {
return true
}
}
return false
}
// ErrPrivate is returned when a connection was refused for where it pointed. It
// deliberately does not say which address it resolved to: the answer is itself
// the thing an attacker is fishing for.
@@ -46,8 +87,15 @@ func (e *ErrPrivate) Error() string {
// Blocked reports whether an address must not be dialled.
func Blocked(ip net.IP) bool {
return ip == nil ||
ip.IsLoopback() ||
if ip == nil {
return true
}
// Before the private check, not after: a tailnet's IPv6 range is unique-local,
// so asking IsPrivate first would refuse it.
if IsTailscale(ip) {
return false
}
return ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
+44
View File
@@ -53,3 +53,47 @@ func TestAllowPrivateStillReaches(t *testing.T) {
}
resp.Body.Close()
}
// Tailscale skal virke, og på begge familier.
//
// IPv4-halvdelen slap allerede igennem, men ved et tilfælde: Gos IsPrivate
// dækker RFC1918 og ikke det CGNAT-område, Tailscale låner. IPv6-halvdelen gjorde
// ikke — fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså
// tilgængelig eller ej alt efter, hvilken adressefamilie man ramte den på.
//
// Testen står her for at holde begge dele fast: at 100.x er tilladt med vilje og
// ikke af held, og at fd7a: ikke ryger tilbage bag muren, næste gang nogen rydder
// op i prædikatet.
func TestTailnetIsNotTheInside(t *testing.T) {
for _, s := range []string{
"100.64.0.0", "100.100.100.100", "100.127.255.255",
"fd7a:115c:a1e0::1", "fd7a:115c:a1e0:ab12:3456:7890:abcd:ef01",
} {
if Blocked(net.ParseIP(s)) {
t.Errorf("Blocked(%s) = true, men det er en tailnet-adresse", s)
}
if !IsTailscale(net.ParseIP(s)) {
t.Errorf("IsTailscale(%s) = false", s)
}
}
// Undtagelsen skal være så smal, som den er navngivet. Naboerne til området
// er ikke Tailscale, og en anden unique-local-adresse er stadig indenfor.
for _, s := range []string{
"100.63.255.255", // lige under 100.64.0.0/10
"100.128.0.0", // lige over
"fd00::1", // unique-local, men ikke Tailscales
"fd7b:115c:a1e0::1",
"192.168.1.150",
} {
if IsTailscale(net.ParseIP(s)) {
t.Errorf("IsTailscale(%s) = true, men den ligger uden for området", s)
}
}
// Og de af dem, der er indenfor, skal stadig være det.
for _, s := range []string{"fd00::1", "fd7b:115c:a1e0::1", "192.168.1.150"} {
if !Blocked(net.ParseIP(s)) {
t.Errorf("Blocked(%s) = false — muren er væk for mere end Tailscale", s)
}
}
}
+159 -8
View File
@@ -3,7 +3,9 @@ package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
@@ -14,6 +16,12 @@ type CalendarAccount struct {
Provider string `json:"provider"`
Account string `json:"account,omitempty"`
// URL er tom for en OAuth-konto og adressen for et abonnement. Den er
// identiteten på et abonnement, som tokenet er det for Google: to rækker med
// den samme adresse for den samme person ville være den samme kalender hentet
// to gange, uden nogen måde at sige hvilken der gælder.
URL string `json:"url,omitempty"`
RefreshToken string `json:"-"` // never leaves the server
AccessToken string `json:"-"`
ExpiresAt time.Time `json:"-"`
@@ -65,10 +73,17 @@ type CalendarEvent struct {
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
access_token, expires_at, last_sync_at, created_at`
// Med adressen. Skrivningen tilføjer den selv, fordi indsættelsen navngiver
// søjlerne; læsningen skal bede om den.
const calendarAccountRead = calendarAccountColumns + `, url`
// CalendarAccountFor returns a person's connection to one provider, or nil.
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
// `url = ''`: en OAuth-konto. Uden det ville et abonnement hos den samme
// udbyder kunne svare på spørgsmålet "hvad er personen forbundet til med
// Google", og et abonnement har hverken token eller konto at svare med.
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountRead+`
FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`, userID, provider)
a, err := db.scanCalendarAccount(row)
if err == sql.ErrNoRows {
return nil, nil
@@ -106,27 +121,37 @@ func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error
// struct with no id in it, and matching on the id would insert a second row
// that the unique index then refuses — which reads to the person as "connecting
// failed" when what happened is that it already had.
// `WHERE url = ''` fordi indekset er delvist siden 0026.
//
// En OAuth-konto er én pr. person og udbyder — det er tokenet, der er
// identiteten — mens et abonnement er én pr. adresse, og man har flere. De to
// bor i samme tabel, fordi et abonnement *er* en kalender med begivenheder i,
// og den halvdel er den samme. Målet for konflikten skal sige, hvilken af de to
// slags rækker der tales om, ellers ved SQLite det ikke.
_, err = db.ExecContext(ctx, `
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, provider) DO UPDATE SET
INSERT INTO calendar_accounts (`+calendarAccountColumns+`, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, provider) WHERE url = '' DO UPDATE SET
account = excluded.account,
refresh_token = excluded.refresh_token,
access_token = excluded.access_token,
expires_at = excluded.expires_at,
last_sync_at = excluded.last_sync_at`,
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix(), a.URL)
if err != nil {
return err
}
if a.URL != "" {
return nil
}
// The row that is actually there wins. On a reconnect the insert above updated
// an existing row, so the id in hand is one nothing points at — and the
// calendars are hung off the id, so writing them against it would attach them
// to nothing.
return db.QueryRowContext(ctx,
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`,
a.UserID, a.Provider).Scan(&a.ID)
}
@@ -362,7 +387,7 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
var a CalendarAccount
var expires, lastSync, created int64
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
&a.AccessToken, &expires, &lastSync, &created)
&a.AccessToken, &expires, &lastSync, &created, &a.URL)
if err != nil {
return a, err
}
@@ -378,3 +403,129 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
a.CreatedAt = time.Unix(created, 0)
return a, nil
}
// --- abonnementer ---------------------------------------------------------------
// SubscribeCalendar adds a calendar somebody only has an address to.
//
// One row in `calendar_accounts` with a url and no tokens, and one row in
// `calendars` hung off it — because a subscription *is* one calendar, where an
// OAuth account is a list of them somebody picks from. Written together so a
// subscription is never a row with nothing under it: the sweep looks for accounts,
// and one without a calendar would be fetched for ever and stored nowhere.
//
// Shown from the start. Ticking a box after adding an address is a second step for
// a decision the person already made by adding it.
func (db *DB) SubscribeCalendar(ctx context.Context, userID, url, name string) (*CalendarAccount, error) {
account := &CalendarAccount{
ID: NewID(),
UserID: userID,
Provider: "ics",
Account: name,
URL: url,
CreatedAt: time.Now(),
}
err := db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
INSERT INTO calendar_accounts (id, user_id, provider, account, url, created_at)
VALUES (?, ?, 'ics', ?, ?, ?)`,
account.ID, userID, name, url, account.CreatedAt.Unix()); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `
INSERT INTO calendars (id, account_id, remote_id, name, shown)
VALUES (?, ?, ?, ?, 1)`,
NewID(), account.ID, url, name)
return err
})
if isUniqueViolation(err) {
return nil, ErrDuplicate
}
if err != nil {
return nil, err
}
return account, nil
}
// Subscriptions lists the calendars a person subscribes to by address.
func (db *DB) Subscriptions(ctx context.Context, userID string) ([]CalendarAccount, error) {
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
FROM calendar_accounts WHERE user_id = ? AND url <> ''
ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CalendarAccount{}
for rows.Next() {
a, err := db.scanCalendarAccount(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// AllSubscriptions is every subscription on the instance, for the sweep.
//
// The sweep runs for the server rather than for a session, so it cannot ask "whose
// is this" the way a handler can — it is handed the user id along with the row and
// writes against that.
func (db *DB) AllSubscriptions(ctx context.Context) ([]CalendarAccount, error) {
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
FROM calendar_accounts WHERE url <> '' ORDER BY last_sync_at`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CalendarAccount{}
for rows.Next() {
a, err := db.scanCalendarAccount(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// RenameSubscribedCalendar writes the name the file calls itself.
//
// A subscription is added with the address as its name, because that is all there
// is to go on before it has been fetched once. The file usually says better —
// X-WR-CALNAME is what every publisher uses — and taking it means the list says
// "Danske helligdage" instead of a URL nobody reads.
//
// Only when the person has not renamed it themselves: a name they typed is an
// instruction, and a name from the file is a guess.
func (db *DB) RenameSubscribedCalendar(ctx context.Context, accountID, name string) error {
if strings.TrimSpace(name) == "" {
return nil
}
return db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
UPDATE calendar_accounts SET account = ?
WHERE id = ? AND (account = '' OR account = url)`, name, accountID); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `
UPDATE calendars SET name = ?
WHERE account_id = ? AND (name = '' OR name = remote_id)`, name, accountID)
return err
})
}
// SubscribedCalendarID is the single calendar row under a subscription.
func (db *DB) SubscribedCalendarID(ctx context.Context, accountID string) (string, error) {
var id string
err := db.QueryRowContext(ctx,
`SELECT id FROM calendars WHERE account_id = ? LIMIT 1`, accountID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}
+161 -36
View File
@@ -16,6 +16,7 @@ type Invite struct {
ID string
Email string
ProjectID string // empty for an invite to the instance rather than to a project
NoteID string // set when the invite is to a single note
Role Role
CreatedBy string
CreatedAt time.Time
@@ -68,36 +69,46 @@ func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) e
})
}
// CreateInvite issues an invite and returns the token for the emailed link.
// projectID may be empty, which invites somebody to the instance rather than to a
// particular project.
// CreateInvite issues an invite to a project and returns the token for the emailed
// link. projectID may be empty, which invites somebody to the instance rather than
// to a particular project.
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if !role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", role)
return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl)
}
// CreateNoteInvite issues an invite to a single note.
//
// Same row, same token, same link as a project invite — the difference is only
// what the person gets when they arrive, and that is settled in AcceptInvite. A
// second table for "invited to a note" would have meant a second signup path to
// keep in step with this one, and the two would drift the first time either was
// touched.
func (db *DB) CreateNoteInvite(ctx context.Context, email, noteID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if noteID == "" {
return "", nil, errors.New("store: a note invite needs a note")
}
return db.createInvite(ctx, &Invite{Email: email, NoteID: noteID, Role: role, CreatedBy: createdBy}, ttl)
}
func (db *DB) createInvite(ctx context.Context, inv *Invite, ttl time.Duration) (string, *Invite, error) {
if !inv.Role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", inv.Role)
}
token, err := auth.NewToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
inv := &Invite{
ID: NewID(),
Email: NormalizeEmail(email),
ProjectID: projectID,
Role: role,
CreatedBy: createdBy,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
inv.ID = NewID()
inv.Email = NormalizeEmail(inv.Email)
inv.CreatedAt = now
inv.ExpiresAt = now.Add(ttl)
var project any
if projectID != "" {
project = projectID
}
_, err = db.ExecContext(ctx,
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
`INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID),
string(inv.Role), auth.HashToken(token),
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
if err != nil {
return "", nil, err
@@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
return nil, ErrInviteInvalid
}
var inv Invite
var projectID sql.NullString
var projectID, noteID sql.NullString
var created, expires int64
var accepted sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
`SELECT id, email, project_id, note_id, role, created_by, created_at, expires_at, accepted_at
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
Scan(&inv.ID, &inv.Email, &projectID, &noteID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInviteInvalid
}
@@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
}
inv.ProjectID = projectID.String
inv.NoteID = noteID.String
inv.CreatedAt = time.Unix(created, 0).UTC()
inv.ExpiresAt = time.Unix(expires, 0).UTC()
return &inv, nil
@@ -144,10 +156,16 @@ type PendingInvite struct {
Email string
ProjectID string
ProjectName string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
// NoteID is set for an invitation to a single note. The note is named by its
// id and never by its title: titles are sealed in the database, and a private
// note's first line is not something an administrator's user list should
// spell out. That there is an account on its way is the administrator's
// business; what it was invited to read is not.
NoteID string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// ListPendingInvites returns invites that are neither accepted nor expired.
@@ -159,7 +177,8 @@ type PendingInvite struct {
// able to say which it is.
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''),
COALESCE(i.note_id, ''), i.role,
COALESCE(u.name, ''), i.created_at, i.expires_at
FROM invites i
LEFT JOIN projects p ON p.id = i.project_id
@@ -175,8 +194,8 @@ func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
for rows.Next() {
var p PendingInvite
var created, expires int64
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
&p.InvitedBy, &created, &expires); err != nil {
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID,
&p.Role, &p.InvitedBy, &created, &expires); err != nil {
return nil, err
}
p.CreatedAt = time.Unix(created, 0).UTC()
@@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
return nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// AcceptInvite marks the invite used and grants what it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
//
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
// same link produce one membership and one failure rather than two memberships.
// same link produce one grant and one failure rather than two.
//
// What is granted depends on what the invite was to: a project membership, a share
// on a single note, or — when it carries neither — nothing beyond the account the
// caller has just created.
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
@@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return ErrInviteInvalid
}
var projectID sql.NullString
var projectID, noteID sql.NullString
var role Role
var invitedBy string
if err := tx.QueryRowContext(ctx,
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &role); err != nil {
`SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &noteID, &role, &invitedBy); err != nil {
return err
}
if noteID.Valid && noteID.String != "" {
// created_by is the person who invited, not the person arriving: the row
// records who did the sharing, the same as a share made from the panel.
//
// The note may have been deleted between the invite and the signup. The
// foreign key would refuse the row and take the whole signup with it, so
// the note is looked up first and a share on a note that is gone is
// simply not made — the account is still worth creating.
var alive int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by <> ?`,
noteID.String, userID).Scan(&alive); err != nil {
return err
}
if alive == 0 {
return nil
}
_, err = tx.ExecContext(ctx, `
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
noteID.String, userID, string(role), invitedBy, time.Now().Unix())
return err
}
if !projectID.Valid || projectID.String == "" {
return nil // an invite to the instance, with no project attached
}
@@ -240,6 +290,81 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
})
}
// --- invitationer til én note ----------------------------------------------------
// NoteInvite is somebody who has been invited to a note and has not arrived yet.
// The email address is the whole of who they are — there is no account to name
// them by, which is the entire reason the row exists.
type NoteInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListNoteInvites is who has been invited to a note and not yet taken it up.
//
// The panel needs them beside the people who *are* on the note: without this, an
// invitation sent yesterday is invisible, and the owner sends it again — a second
// link to the same inbox, and no way to take either back.
//
// Expired ones are left out, the same rule the administrator's list follows: an
// invite past its date is not something anybody can act on.
func (db *DB) ListNoteInvites(ctx context.Context, noteID string) ([]NoteInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE note_id = ? AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, noteID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []NoteInvite{}
for rows.Next() {
var i NoteInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteNoteInvite withdraws an invitation to a note, and refuses to withdraw
// anything else.
//
// The note id is part of the WHERE rather than checked beforehand: the owner has
// been established for *this* note, and one statement that can only touch this
// note's rows cannot be talked into deleting a project invite by id.
func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND note_id = ?`, inviteID, noteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE note_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?`,
noteID, NormalizeEmail(email), time.Now().Unix()).Scan(&n)
return n > 0, err
}
// --- password resets ----------------------------------------------------------
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"sort"
"strings"
"unicode"
)
// MentionsIn reads the people named with an @ out of a body, and returns their
// ids in the order they are written.
//
// Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum,
// bindestreg og punktum 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
// hedder "Sofie i morgen", og ellers til "Sofie", alt efter hvor grådigt det var
// skrevet. 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å: en omtale giver adgang,
// og adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse.
//
// Længste navn først, så "@Sofie Jensen" bliver Sofie Jensen og ikke Sofie med et
// efternavn hængende bagefter — også når begge findes som konti.
func MentionsIn(body string, people []Person) []string {
if body == "" || len(people) == 0 {
return nil
}
lower := strings.ToLower(body)
byLength := make([]Person, len(people))
copy(byLength, people)
sort.SliceStable(byLength, func(i, j int) bool {
return len(byLength[i].Name) > len(byLength[j].Name)
})
// Hvilke tegn der allerede er læst som en del af en omtale. Uden det ville
// "@Sofie Jensen" også tælle som en omtale af Sofie, hvis begge er konti — og
// noten ville blive delt med en, der ikke står i den.
claimed := make([]bool, len(body))
found := map[string]int{} // bruger -> hvor i teksten, så rækkefølgen kan bæres
for _, p := range byLength {
name := strings.ToLower(strings.TrimSpace(p.Name))
if name == "" || p.ID == "" {
continue
}
needle := "@" + name
for at := 0; ; {
i := strings.Index(lower[at:], needle)
if i < 0 {
break
}
i += at
at = i + len(needle)
end := i + len(needle)
if overlaps(claimed, i, end) || !isMention(body, i, end) {
continue
}
for j := i; j < end; j++ {
claimed[j] = true
}
if _, seen := found[p.ID]; !seen {
found[p.ID] = i
}
}
}
out := make([]string, 0, len(found))
for id := range found {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return found[out[i]] < found[out[j]] })
return out
}
func overlaps(claimed []bool, from, to int) bool {
for i := from; i < to && i < len(claimed); i++ {
if claimed[i] {
return true
}
}
return false
}
// isMention er de to kanter om navnet.
//
// Foran: begyndelsen af noten eller noget, der ikke er en bogstavstavelse — så en
// e-mailadresse ikke bliver til en omtale, fordi der tilfældigvis står et navn
// efter snabel-a'et.
//
// Bagved: ikke midt i et længere ord. "@Sofies kalender" er skrevet om Sofie og
// tæller med — ejefaldets s hører til sætningen, ikke til en anden person — men
// "@Sofienberg" er et sted og ikke en, der skal have noten delt. Grænsen er sat
// ved, at et bogstav *lige* efter navnet kun må være et ejefalds-s.
func isMention(body string, from, to int) bool {
runes := []rune(body[:from])
if len(runes) > 0 {
prev := runes[len(runes)-1]
if unicode.IsLetter(prev) || unicode.IsDigit(prev) || prev == '@' {
return false
}
}
rest := body[to:]
if rest == "" {
return true
}
next := []rune(rest)
if next[0] == 's' || next[0] == 'S' {
next = next[1:]
if len(next) == 0 {
return true
}
}
return !unicode.IsLetter(next[0]) && !unicode.IsDigit(next[0])
}
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"reflect"
"testing"
)
func TestMentionsIn(t *testing.T) {
people := []Person{
{ID: "sofie", Name: "Sofie"},
{ID: "sofie-jensen", Name: "Sofie Jensen"},
{ID: "kristian", Name: "Kristian"},
}
for _, tc := range []struct {
name string
body string
want []string
}{
{"ingen omtale", "En helt almindelig note om noget", nil},
{"én omtale", "@Sofie kan du kigge på det her?", []string{"sofie"}},
{"midt i en sætning", "Aftalt med @Kristian i går", []string{"kristian"}},
{"uden hensyn til store bogstaver", "@sofie ja", []string{"sofie"}},
{"to omtaler i den rækkefølge de står", "@Kristian og @Sofie", []string{"kristian", "sofie"}},
{"den samme to gange er én", "@Sofie ... og @Sofie igen", []string{"sofie"}},
// Det lange navn vinder, og det korte tælles ikke med oveni: noten deles
// med den, der står i den, og ikke også med en, der ikke gør.
{"længste navn vinder", "@Sofie Jensen kigger på det", []string{"sofie-jensen"}},
// En adresse er ikke en omtale. Ellers ville enhver note med en mailadresse
// i sig dele sig selv med den, der tilfældigvis hedder noget efter snabel-a.
{"e-mailadresse", "skriv til hej@Sofie.dk", nil},
{"ejefald tæller med", "det står i @Sofies kalender", []string{"sofie"}},
{"et længere ord gør ikke", "vi mødtes på @Sofienberg", nil},
{"ukendt navn", "@Ingen her", nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := MentionsIn(tc.body, people)
if len(got) == 0 && len(tc.want) == 0 {
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("MentionsIn(%q) = %v, want %v", tc.body, got, tc.want)
}
})
}
}
// Ingen mennesker, ingen omtaler: opslaget sker i navnene, så en tom liste er et
// tomt svar frem for et gæt på, hvad et @ mon betød.
func TestMentionsInWithoutPeople(t *testing.T) {
if got := MentionsIn("@Sofie hej", nil); got != nil {
t.Errorf("got %v, want nothing to resolve against", got)
}
}

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