Compare commits

...
3 Commits
Author SHA1 Message Date
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
10 changed files with 498 additions and 99 deletions
+9 -3
View File
@@ -104,15 +104,21 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | 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.
+16
View File
@@ -64,6 +64,22 @@ 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)
}
httpapi.Version = version
api := httpapi.New(cfg, db, log, webAssets(log))
+15 -9
View File
@@ -144,14 +144,20 @@ 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 not encrypted
## What is encrypted, and what is not
Note bodies are stored as plain text. **A note holding a password or an API key is
holding it in plain text in every backup you can download.** The mailbox
credentials, OAuth tokens and AI keys are sealed; note bodies are not, and neither
are task titles, descriptions or comments.
**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.
Encrypting them is on the list and is bigger than it sounds — the full-text index
and the folded search copy are both derived from the body, so both would have to go
and search would have to be rewritten. Until then: keep secrets somewhere built for
them.
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.
+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)
}
}
}
@@ -0,0 +1,75 @@
-- verdande:rebuild-tables
--
-- Notekroppe forsegles.
--
-- 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 denne installation.
--
-- # Hvorfor det ikke var gjort for længe siden
--
-- Kroppen var kopieret to steder mere, og begge kopier var klartekst:
--
-- * `fold` — en genereret søjle med title || ' ' || body, translittereret, så
-- "grøn" kan findes ved at skrive "gron".
-- * `notes_fts` — et FTS5-indeks over titel, krop og fold.
--
-- At forsegle kroppen og lade dem stå ville have været teater: en angriber med
-- filen kunne læse hver note ud af indekset. Så de skal begge væk, og søgningen
-- skal skrives om til at åbne teksten i Go.
--
-- # Hvad det koster, og hvornår formen er forkert
--
-- Søgningen læser nu hver note. Målt: 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, men med at
-- folde og småskrive hver krop; det er lineært, så regn med omtrent 350 ms ved ti
-- tusind noter. Dér er det den forkerte form, og svaret er da et *nøglet* indeks:
-- en HMAC pr. ord under den samme nøgle. Bemærk hvad det koster, når det bliver
-- aktuelt — et nøglet indeks kan slå et helt ord op og ikke andet, så præfiks- og
-- delordssøgningen, der findes i dag, ville falde bort.
--
-- # Hvad der stadig ligger i klartekst
--
-- Alt andet. Opgavetitler, beskrivelser, kommentarer — og `note_links`, som er
-- id'er udledt af kroppen, så en fil røber stadig *hvilke* projekter og opgaver en
-- note peger på, men ikke hvad der står. Det er skrevet i SECURITY.md, og det er
-- ikke løst her.
--
-- # Selve ombygningen
--
-- SQLite kan ikke fjerne en genereret søjle på plads, så tabellen bygges om.
-- Rækkerne kopieres med kroppen, som den er: forseglingen sker i Go, hvor nøglen
-- er, og en opstart efter denne migration går rækkerne igennem og lukker dem. Til
-- den tid kan en søjle indeholde begge dele, og `v1:`-præfikset findes netop for
-- at kunne læses uden at gætte.
CREATE TABLE notes_sealed (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
archived_at INTEGER
);
INSERT INTO notes_sealed
(id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at)
SELECT id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at
FROM notes;
DROP TRIGGER notes_fts_insert;
DROP TRIGGER notes_fts_delete;
DROP TRIGGER notes_fts_update;
DROP TABLE notes_fts;
DROP TABLE notes;
ALTER TABLE notes_sealed RENAME TO notes;
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
WHERE deleted_at IS NULL;
+125 -83
View File
@@ -3,7 +3,9 @@ package store
import (
"context"
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
"time"
)
@@ -89,6 +91,24 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
// it, and every caller gets the same answer because none of them can set it.
n.Title = firstLine(n.Body)
// Forseglet på vej ned, åbnet på vej op.
//
// 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 det er dér, folk skriver hvad noten handler om.
//
// `n` selv røres ikke. Kalderen har den note i hånden og læser videre i den
// bagefter; at forsegle den på plads ville give en note, hvis krop er
// ciffertekst, til den, der lige har gemt den.
title, err := db.sealValue(n.Title)
if err != nil {
return fmt.Errorf("seal note title: %w", err)
}
body, err := db.sealValue(n.Body)
if err != nil {
return fmt.Errorf("seal note body: %w", err)
}
return db.Tx(ctx, func(tx *sql.Tx) error {
var projectID any
if n.ProjectID != "" {
@@ -108,7 +128,7 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
body = excluded.body,
pinned = excluded.pinned,
updated_at = excluded.updated_at`,
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
n.ID, projectID, title, body, createdBy, n.Pinned,
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
return err
}
@@ -182,7 +202,7 @@ func firstLine(body string) string {
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
row := db.QueryRowContext(ctx,
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
n, err := scanNote(row)
n, err := db.scanNote(row)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -263,99 +283,104 @@ func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note,
// SearchNotes matches title and body, in either spelling of a Danish word.
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
if strings.TrimSpace(query) == "" {
terms := SearchTerms(query)
if len(terms) == 0 {
return nil, nil
}
if limit <= 0 {
limit = 50
}
expr := MatchExprOver(query, "title", "body")
if expr == "" {
return nil, nil
// Hver note læses og åbnes.
//
// Der er ikke noget indeks at spørge længere, og det er med vilje: `notes_fts`
// og den genererede `fold`-søjle var begge klartekstkopier af kroppen, så en
// forseglet krop ved siden af dem ville have været teater — en fil ville stadig
// røbe hver note. Se 0027.
//
// Målt frem for gættet: tolv hundrede noter à 3,3 kB koster **33–43 ms** pr.
// søgning. Fladen venter 250 ms efter sidste tastetryk, før den spørger, så det
// ligger inde i pausen og ses ikke.
//
// Tiden går ikke med at åbne konvolutterne — fire megabyte AES-GCM er få
// millisekunder — men med at folde og småskrive hver krop for hver søgning.
// Det er lineært, så regn med omtrent 350 ms ved ti tusind noter, og dér er det
// den forkerte form. Svaret er da et *nøglet* indeks: en HMAC pr. ord under
// samme nøgle. Bemærk hvad det koster, når det bliver aktuelt — et nøglet
// indeks kan slå et helt ord op og ikke andet, så præfiks- og delordssøgningen,
// der findes i dag, ville falde bort.
//
// Papirkurven springes over i SQL frem for i løkken: det er den ene begrænsning,
// der kan bruge et indeks, og den fjerner alt hvad der er slettet uden at åbne
// det.
notes, err := db.notesWhere(ctx, `WHERE deleted_at IS NULL`)
if err != nil {
return nil, err
}
// Ordered by how well it matched, not by when it was last touched.
//
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
// useless at twelve hundred: every one of them was imported the same evening,
// so `updated_at` says nothing, and the limit then cut the list at fifty
// arbitrary rows. 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.
//
// 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. `fold`
// carries the same text transliterated, so it must weigh the same as the body
// or a Danish match would outrank an exact one.
//
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
// subquery throws it away.
found, err := db.notesJoin(ctx, noteColumnsQualified, `
JOIN notes_fts ON notes_fts.rowid = notes.rowid
WHERE notes.deleted_at IS NULL
AND notes_fts MATCH ?
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
LIMIT ?`, expr, limit)
if err != nil || len(found) > 0 {
return found, err
type scored struct {
note Note
score int
}
return db.searchNotesInside(ctx, query, limit)
found := make([]scored, 0, limit)
for _, note := range notes {
score := scoreNote(note, terms)
if score == 0 {
continue
}
found = append(found, scored{note: note, score: score})
}
// Bedste først, og ved lige stand den senest rørte. Uden det andet led er
// rækkefølgen mellem to lige gode svar den, SQLite tilfældigvis gav dem, og den
// skifter mellem to søgninger på det samme.
sort.SliceStable(found, func(i, j int) bool {
if found[i].score != found[j].score {
return found[i].score > found[j].score
}
return found[i].note.UpdatedAt.After(found[j].note.UpdatedAt)
})
out := make([]Note, 0, min(limit, len(found)))
for i, f := range found {
if i >= limit {
break
}
out = append(out, f.note)
}
return out, nil
}
// searchNotesInside finds a word that lives *inside* another word.
// scoreNote er hvor godt en note svarer på ordene, eller nul for slet ikke.
//
// An index is built of tokens, and a token is what a tokenizer decided was a
// word. `gamerule keepInventory true` is three of them, and the middle one is
// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and
// gets nothing. Reported as "søgning har ikke det store held", which from where
// the person is sitting is exactly what it is: the note is right there, and the
// search says there is nothing.
// Erstatter bm25, som forsvandt med indekset. Den er grovere, og det er værd at
// vide hvor: bm25 vægter et ord efter hvor sjældent det er i hele samlingen, og
// det kan ikke gøres uden en samling at tælle i. Det, der er beholdt, er den
// halvdel, der betød mest — **titlen vejer ti gange kroppen**. Et ord i titlen er
// hvad noten *handler om*; det samme ord én gang i en lang krop er en omtale.
//
// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*`
// cannot, because it is not a prefix of anything — and the two are joined with AND,
// so the half that works is thrown away by the half that cannot.
//
// This is not an exotic case 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: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.
//
// So: the index answers first, and when it has nothing, the text is read. Every
// term has to appear somewhere in the note, anywhere, in any word.
//
// # What it costs, and when to stop doing it this way
//
// A LIKE with a leading % cannot use an index; this reads every note. At twelve
// hundred notes that is four megabytes and a millisecond or two, and it only runs
// when the fast path already came back empty — which is the search that was about
// to disappoint somebody. At a hundred thousand notes it would be the wrong shape,
// and the answer then is an FTS5 `trigram` index, not a bigger scan.
//
// It does not rank. bm25 needs a match to rank, and there is no match here — that
// is the whole situation. The rows are the most recently touched first, and the
// set is small, because the index already said there was nothing in it.
func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) {
terms := SearchTerms(query)
if len(terms) == 0 {
return nil, nil
}
// Alle ord skal findes. Det er den samme regel som før, hvor leddene var bundet
// med AND.
func scoreNote(note Note, terms []string) int {
title := FoldDanish(strings.ToLower(note.Title))
body := FoldDanish(strings.ToLower(note.Body))
// `fold` is `title || ' ' || body` with the Danish letters transliterated, so
// one column covers the title, the body and "gron" for "grøn" at once. LIKE is
// case-insensitive for ASCII in SQLite, which is what makes `keepInventory`
// answer to "inventory".
//
// The needle is safe to interpolate into a pattern because `tokenize` splits on
// everything that is not a letter or a digit: a term cannot contain % or _, so
// there is no wildcard for somebody to smuggle in. It is still a bound
// parameter — that is about SQL, and this is about LIKE.
where := "WHERE deleted_at IS NULL"
args := make([]any, 0, len(terms)+1)
score := 0
for _, term := range terms {
where += " AND fold LIKE ?"
args = append(args, "%"+FoldDanish(term)+"%")
needle := FoldDanish(strings.ToLower(term))
inTitle := strings.Count(title, needle)
inBody := strings.Count(body, needle)
if inTitle == 0 && inBody == 0 {
// Ét ord, der ikke findes, er et nej for hele noten.
return 0
}
// 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å.
score += 10*min(inTitle, 3) + min(inBody, 5)
}
args = append(args, limit)
return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...)
return score
}
// SetNoteTimes writes the dates a note had somewhere else.
@@ -441,7 +466,7 @@ func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any)
var out []Note
for rows.Next() {
n, err := scanNote(rows)
n, err := db.scanNote(rows)
if err != nil {
return nil, err
}
@@ -469,7 +494,11 @@ func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
return out, rows.Err()
}
func scanNote(row scanner) (Note, error) {
// scanNote læser en række og åbner det, der er forseglet.
//
// En metode og ikke en funktion, fordi nøglen sidder på `db`. Det er den eneste
// grund; alt andet i den er den samme aflæsning som før.
func (db *DB) scanNote(row scanner) (Note, error) {
var n Note
var projectID, createdBy sql.NullString
var created, updated int64
@@ -491,5 +520,18 @@ func scanNote(row scanner) (Note, error) {
t := time.Unix(archived.Int64, 0)
n.ArchivedAt = &t
}
// Åbnet her, ét sted, så ingen kalder kan komme til at læse ciffertekst.
//
// En værdi skrevet før der var en nøgle kommer tilbage som sig selv — `v1:`
// findes netop for at kunne læse en blandet søjle — så en installation, der
// endnu ikke har fået sin bagudfyldning, virker imens.
for _, field := range []*string{&n.Title, &n.Body} {
plain, err := db.unsealValue(*field)
if err != nil {
return n, fmt.Errorf("open note %s: %w", n.ID, err)
}
*field = plain
}
return n, nil
}
+116
View File
@@ -277,3 +277,119 @@ func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
}
}
// Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer.
//
// Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes
// gennem fladen. 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.
func TestANoteIsNotStoredInTheClear(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const secretLine = "gamerule keepInventory true"
n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine}
if err := db.SaveNote(ctx, n); err != nil {
t.Fatal(err)
}
var title, body string
if err := db.QueryRowContext(ctx,
`SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil {
t.Fatal(err)
}
if strings.Contains(body, secretLine) {
t.Errorf("kroppen står i klartekst i rækken: %s", body)
}
// Og titlen med. 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.
if strings.Contains(title, "Minecraft") {
t.Errorf("titlen står i klartekst i rækken: %s", title)
}
// Kalderens egen note er urørt: den har den i hånden og læser videre i den.
if n.Body != "Minecraft Bedrock\n\n"+secretLine {
t.Errorf("gemningen ændrede kalderens note: %q", n.Body)
}
// Læst tilbage er den sig selv igen.
back, err := db.Note(ctx, n.ID)
if err != nil {
t.Fatal(err)
}
if back.Body != n.Body || back.Title != "Minecraft Bedrock" {
t.Errorf("noten kom anderledes tilbage: %+v", back)
}
// Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et
// indeks, der var en klartekstkopi af den.
found, err := db.SearchNotes(ctx, "keep inventory", 10)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found))
}
}
// Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades.
//
// Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver
// *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer
// igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle.
func TestOldNotesAreSealedOnStartup(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
// Skrevet uden om Go-laget, som en note fra før 0027 ligger.
if _, err := db.ExecContext(ctx, `
INSERT INTO notes (id, title, body, created_by, created_at, updated_at)
VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`,
userID); err != nil {
t.Fatal(err)
}
sealed, err := db.SealNotes(ctx)
if err != nil {
t.Fatal(err)
}
if sealed != 1 {
t.Fatalf("%d noter blev lukket, want 1", sealed)
}
var body string
var updated int64
if err := db.QueryRowContext(ctx,
`SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil {
t.Fatal(err)
}
if strings.Contains(body, "hemmeligt") {
t.Errorf("den gamle note ligger stadig åben: %s", body)
}
// `updated_at` er ikke rørt. Det betyder "hvornår skrev dette program noten
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville kaste den
// rækkefølge væk, importen brugte en dag på at hente hjem.
if updated != 200 {
t.Errorf("bagudfyldningen flyttede updated_at til %d", updated)
}
// Og den kan læses.
back, err := db.Note(ctx, "n-gammel")
if err != nil {
t.Fatal(err)
}
if back == nil || !strings.Contains(back.Body, "hemmeligt") {
t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back)
}
// Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit
// præfiks.
again, err := db.SealNotes(ctx)
if err != nil {
t.Fatal(err)
}
if again != 0 {
t.Errorf("anden kørsel lukkede %d noter om igen", again)
}
}
+75
View File
@@ -38,6 +38,81 @@ type DB struct {
// misplaced key look like a corrupt disk.
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
// SealNotes lukker de noter, der stadig ligger åbne.
//
// Kaldes én gang ved opstart, efter nøglen er sat. Uden den ville kroppene blive
// forseglet efterhånden som de bliver *skrevet* — sådan som instansens
// indstillinger gør det — og for et arkiv på tolv hundrede importerede noter, som
// ingen redigerer igen, betyder det aldrig. En bagudfyldning er den eneste måde,
// den her ændring rent faktisk beskytter noget på.
//
// Idempotent: en note, der allerede er forseglet, kendes på sit præfiks og røres
// ikke. Så den kan køre ved hver opstart uden at koste andet end et gennemløb af
// id'er og en længde.
//
// Skriver med SQL frem for gennem SaveNote. SaveNote udleder titlen af kroppen og
// bygger links om, og begge dele er allerede rigtige her — det, der skal ske, er
// alene, at de to søjler skal skiftes ud med sig selv, lukket.
func (db *DB) SealNotes(ctx context.Context) (int, error) {
if db.box == nil {
return 0, nil
}
rows, err := db.QueryContext(ctx, `SELECT id, title, body FROM notes`)
if err != nil {
return 0, err
}
type open struct{ id, title, body string }
var pending []open
for rows.Next() {
var o open
if err := rows.Scan(&o.id, &o.title, &o.body); err != nil {
rows.Close()
return 0, err
}
if secret.IsSealed(o.body) && (o.title == "" || secret.IsSealed(o.title)) {
continue
}
pending = append(pending, o)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(pending) == 0 {
return 0, nil
}
// Hele bunken i én transaktion. En halvt forseglet tabel er ikke i stykker —
// præfikset gør, at begge slags kan læses — men en afbrudt kørsel, der skal
// tages om, er lettere at ræsonnere om, når den enten skete eller ikke gjorde.
err = db.Tx(ctx, func(tx *sql.Tx) error {
for _, o := range pending {
title, err := db.sealValue(o.title)
if err != nil {
return err
}
body, err := db.sealValue(o.body)
if err != nil {
return err
}
// `updated_at` røres ikke. Det betyder "hvornår skrev dette program noten
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville
// kaste den rækkefølge væk, importen brugte en dag på at hente hjem.
if _, err := tx.ExecContext(ctx,
`UPDATE notes SET title = ?, body = ? WHERE id = ?`, title, body, o.id); err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, err
}
return len(pending), nil
}
// dsn is the connection string.
//
// SQLite is a single file, but it is being driven by an HTTP server with concurrent