Compare commits

...
71 Commits
Author SHA1 Message Date
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
116 changed files with 7135 additions and 473 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/
+13 -6
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
@@ -104,15 +105,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.
+26
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))
+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.
+19
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 |
+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
+16 -6
View File
@@ -11,7 +11,8 @@ 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.
@@ -22,15 +23,22 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views, 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.
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
out as Web Push to an iPhone or a Mac.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
@@ -48,7 +56,9 @@ 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.*
![Settings, integrations](screenshots/settings.png)
+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.
+150
View File
@@ -805,6 +805,28 @@ 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]
@@ -2079,6 +2101,48 @@ 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]
@@ -3030,6 +3094,69 @@ 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" }
"404": { $ref: "#/components/responses/NotFound" }
post:
tags: [notes]
summary: Share a note with a person, or change the role they hold.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [user_id]
properties:
user_id: { type: string }
role: { type: string, enum: [viewer, editor] }
responses:
"204": { description: Shared }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/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]
@@ -3865,6 +3992,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.
@@ -3881,6 +4016,21 @@ 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] }
Mailbox:
type: object
description: One connected mailbox. The password and any token are never returned.
+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: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 217 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.
+19 -3
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),
}
}
+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 {
+35
View File
@@ -2,6 +2,7 @@ package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"errors"
"fmt"
@@ -82,6 +83,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") }
+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)
+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"} {
+11
View File
@@ -360,8 +360,19 @@ 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,
}
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
}
+32 -12
View File
@@ -215,20 +215,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 +276,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
}
+193
View File
@@ -2,12 +2,15 @@ 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"
@@ -716,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)
@@ -1529,3 +1612,113 @@ func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
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")
}
}
+12
View File
@@ -202,8 +202,20 @@ 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),
}
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
}
+63 -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.
@@ -206,9 +223,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 {
@@ -238,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
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
}
@@ -280,6 +313,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"`)
+181
View File
@@ -0,0 +1,181 @@
package httpapi
import (
"net/http"
"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
}
out := make([]noteShareJSON, 0, len(shares))
already := map[string]bool{}
for _, sh := range shares {
out = append(out, noteShareJSON{
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
Role: string(sh.Role),
})
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})
}
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
}
type noteShareJSON struct {
User personJSON `json:"user"`
Role string `json:"role"`
}
type shareNoteRequest struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
// handleShareNote grants a person a role on a note, or changes the role they hold.
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
}
if req.UserID == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
// 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 == userFrom(r.Context()).ID {
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
return
}
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
s.storeError(w, r, "share note", 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
}
w.WriteHeader(http.StatusNoContent)
}
+199
View File
@@ -0,0 +1,199 @@
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.StatusNoContent {
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.StatusNoContent {
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.StatusNoContent {
t.Error("a note was shared at the owner role")
}
}
+33 -2
View File
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
return out
}
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
target := "/projekt/" + projectID
if projectID == "" {
target = "/"
}
payload := push.Payload{Title: title, Body: body, URL: target}
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
}
// Warn, not Debug.
@@ -257,6 +265,29 @@ 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 {
+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)
}
}
+43 -1
View File
@@ -63,6 +63,8 @@ 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
resetLimiter *limiter
// Second factors are counted against the *account*, not the address. An
@@ -108,7 +110,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))
@@ -343,6 +348,11 @@ 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)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
@@ -392,6 +402,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) {
@@ -535,6 +551,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)
})
})
})
@@ -657,6 +674,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)
})
}
+70 -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
}
@@ -698,3 +702,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))
}
+47
View File
@@ -1817,3 +1817,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
}
}
}
// A snoozed task sinks to the bottom of the list and greys until its time; waking
// it puts it back. The list order is what the row's dimming rides on.
func TestSnoozeSinksAndWakes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
aID, _ := a["id"].(string)
// Snooze Alfa two days out.
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
if resp.StatusCode != http.StatusOK {
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
}
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
t.Error("the response does not carry snoozed_until")
}
// Alfa now sorts after Bravo, though it was created first.
order := func() []string {
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
var out []string
for _, raw := range list["tasks"].([]any) {
out = append(out, raw.(map[string]any)["content"].(string))
}
return out
}
got := order()
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
t.Errorf("snoozed task did not sink: order = %v", got)
}
// Wake it, and it comes back above Bravo.
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
if resp.StatusCode != http.StatusOK {
t.Fatalf("wake: %d %v", resp.StatusCode, body)
}
if s, _ := body["snoozed_until"].(string); s != "" {
t.Errorf("waking left snoozed_until set: %q", s)
}
if got := order(); got[0] != "Alfa" {
t.Errorf("woken task did not return: order = %v", got)
}
}
+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))
}
}
}
+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
}
@@ -0,0 +1,30 @@
-- Abonnement på en kalender, man kun har en adresse til.
--
-- Google-vejen er OAuth: én konto pr. person, hvor tokenet er identiteten, og
-- `calendar_accounts_once` sørgede for netop det — én række pr. (person,
-- udbyder). Et abonnement er en anden slags ting. Der er intet token og ingen
-- konto, kun en adresse, og man har flere: helligdage, en fodboldklub, en
-- fælleskalender fra arbejdet. Det er derfor det blev bedt om som "flere
-- kalendere".
--
-- Samme tabel frem for en ny. Et abonnement er én kalender med begivenheder i, og
-- det er præcis det, `calendars` og `calendar_events` allerede beskriver — med
-- vinduet, `shown`, farven 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.
--
-- Adressen bliver identiteten, 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, og der er ingen måde at sige hvilken der gælder.
ALTER TABLE calendar_accounts ADD COLUMN url TEXT NOT NULL DEFAULT '';
-- Det gamle indeks holder stadig for OAuth-rækkerne — dem uden adresse — og
-- slipper abonnementerne fri. Delvise indekser er den ene ting SQLite kan her,
-- som en tabelombygning ellers skulle til for.
DROP INDEX calendar_accounts_once;
CREATE UNIQUE INDEX calendar_accounts_once
ON calendar_accounts (user_id, provider) WHERE url = '';
CREATE UNIQUE INDEX calendar_subscription_once
ON calendar_accounts (user_id, url) WHERE url <> '';
@@ -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;
+79
View File
@@ -0,0 +1,79 @@
-- Kvasir: what an assistant did, and what it acted on.
--
-- Two things, and they are separate on purpose: one is about the outside world,
-- and would be worth having even if Kvasir never existed.
-- 1. Where a task came from, out there.
--
-- Nothing today can answer "has this mail already become a task?". A second
-- reader of the same mailbox — another assistant, a rerun of the same sweep,
-- a person — makes a second task, and the only sign is the duplicate itself.
-- That has happened in practice, twice in one run.
--
-- Namespaced rather than a bare id: `gmail:thread:18f2c…`, `imap:<host>:<uid>`,
-- `ics:<uid>`. A Gmail thread id and an IMAP uid look nothing alike until the
-- day they collide, and a namespace costs one colon. It also means the next
-- source needs no column — which is the point of a key rather than a foreign
-- key. There is no messages table to point at, and this has to work without one.
ALTER TABLE tasks ADD COLUMN source_key TEXT NOT NULL DEFAULT '';
-- The index is the interlock, not an optimisation. Two agents reading the same
-- inbox must be unable to make two tasks, and "must be unable" is a constraint
-- rather than a convention somebody remembers to follow.
--
-- Scoped to the author: two people on one instance connect their own mailboxes,
-- and the same thread reaching both of them is two tasks, correctly. Partial
-- because `created_by` is ON DELETE SET NULL — a task outlives its author, and
-- rows that have lost theirs must not start colliding with each other.
CREATE UNIQUE INDEX tasks_once_per_source
ON tasks (created_by, source_key)
WHERE source_key <> '' AND created_by IS NOT NULL;
-- 2. Kvasir's ledger.
--
-- The dial that lets an assistant act on its own is only bearable because of
-- this table. Two questions have to be answerable at any moment: what did it do
-- today, and how do I undo it. Neither is answerable from the tasks themselves.
--
-- A suggestion and a completed action are the SAME ROW in different states. That
-- is the whole design: "suggest" and "act" are not two code paths, they are one
-- path that writes a different `state`. Accepting a suggestion moves it to done;
-- undoing an action moves it to undone. Nothing has to be written twice, and the
-- feed reads the same either way.
--
-- `before_json` is the undo. Not a diff and not a description — the fields as
-- they were, so putting them back needs no reasoning about what changed. It is
-- also the honest record: a task edited by hand afterwards will not match, and
-- an undo that would overwrite newer work should say so rather than win.
CREATE TABLE kvasir_actions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- 'file', 'rewrite', 'split', 'nudge', 'prioritise'. Free text rather than a
-- CHECK: a new capability must not need a migration to be recorded, and an
-- unknown kind in the feed is a row nobody rendered, not a broken database.
kind TEXT NOT NULL,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
-- One sentence, written when the action was decided. Stored rather than
-- rebuilt: it says what Kvasir believed at the time, and the project it
-- names may since have been renamed or deleted.
summary TEXT NOT NULL,
-- Why it thought so. Shown on request, because a suggestion you cannot
-- interrogate is one you either trust blindly or ignore entirely.
reason TEXT NOT NULL DEFAULT '',
before_json TEXT NOT NULL DEFAULT '{}',
after_json TEXT NOT NULL DEFAULT '{}',
-- suggested → done | dismissed, and done → undone.
state TEXT NOT NULL CHECK (state IN ('suggested', 'done', 'dismissed', 'undone')),
created_at INTEGER NOT NULL,
decided_at INTEGER NOT NULL DEFAULT 0
);
-- The feed asks "what is waiting for me, newest first", and the corrections
-- Kvasir learns from ask "what did I get wrong lately" — both read by user and
-- order by time.
CREATE INDEX kvasir_actions_by_user ON kvasir_actions (user_id, created_at DESC);
@@ -0,0 +1,11 @@
-- A TOTP code is meant to be usable once. RFC 6238 says so in as many words: the
-- verifier must reject a code that has already been accepted, or a code observed in
-- flight — over the shoulder, off a phishing page, out of a proxy — can be replayed
-- for the rest of its thirty-second window.
--
-- totp_last_step records the counter of the most recently accepted code (Unix time
-- divided by the period). A login accepts a code only when its step is strictly
-- greater, so the same code cannot be spent twice and time cannot run backwards.
-- Null means no code has been accepted yet, which is every account until its owner
-- next signs in with one.
ALTER TABLE users ADD COLUMN totp_last_step INTEGER;
@@ -0,0 +1,30 @@
-- Sharing a note with a person, rather than only by filing it in a shared project.
--
-- Until now a note was shared exactly one way: put it in a project, and everyone
-- who could read the project could read the note. That is the right tool when the
-- note belongs with a body of work, and the wrong one when it is just "let Sofie
-- see this" — it makes you stand up a project to hand over a single page.
--
-- A note_shares row is that direct grant: this note, this person, this role. It
-- sits alongside the project path, not instead of it — a note can be loose, filed,
-- shared with people, or any combination, and access is the most that any of those
-- allow. Only the note's owner (its created_by) writes these rows.
--
-- role is 'viewer' or 'editor', the same words a project membership uses, so there
-- is one idea of what a role means in this program and not two.
CREATE TABLE note_shares (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer',
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
created_at INTEGER NOT NULL,
-- One share per person per note: sharing again changes the role, it does not
-- add a second row that the first would then quietly disagree with.
PRIMARY KEY (note_id, user_id)
);
-- "Which notes are shared with me" is asked on every visit to the notes page, to
-- build the Delt med mig group and to widen what the list may show. It is a lookup
-- by user_id, so that is what carries the index.
CREATE INDEX note_shares_by_user ON note_shares (user_id);
@@ -0,0 +1,7 @@
-- Snooze: put a task out of the way without finishing or filing it. A snoozed task
-- sinks to the bottom of its list and greys out until the time it is snoozed to,
-- then comes back on its own — no job wakes it, because "snoozed" is simply
-- snoozed_until being still in the future, read against the clock each time.
--
-- Null means not snoozed, which is every task until somebody snoozes one.
ALTER TABLE tasks ADD COLUMN snoozed_until INTEGER;
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"context"
"testing"
)
func TestNormalizeName(t *testing.T) {
cases := map[string]string{
"andreas": "Andreas",
"Andreas": "Andreas",
"anne mette": "Anne Mette",
" kristian ": "Kristian",
"anne mette": "Anne Mette",
"McDonald": "McDonald", // an existing inner capital is kept
"óscar": "Óscar", // Unicode initial
"æblegaard": "Æblegaard",
"": "",
" ": "",
"mette-marie sø": "Mette-marie Sø", // only word-initials are raised
"iPhone listen": "IPhone Listen", // deliberately: the first letter is raised
}
for in, want := range cases {
if got := NormalizeName(in); got != want {
t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want)
}
}
}
func TestCreateUserCapitalisesName(t *testing.T) {
db := openTest(t)
ctx := context.Background()
u := &User{Email: "andreas@example.dk", Name: "andreas", PasswordHash: "x"}
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
t.Fatalf("create user: %v", err)
}
got, err := db.UserByEmail(ctx, "andreas@example.dk")
if err != nil {
t.Fatalf("read back: %v", err)
}
if got.Name != "Andreas" {
t.Errorf("stored name = %q, want %q", got.Name, "Andreas")
}
}
func TestNormalizeUserNamesBackfill(t *testing.T) {
db := openTest(t)
ctx := context.Background()
// An account as it looked before the app capitalised names: made properly,
// then set back to lower case behind the store's back to stand in for an old row.
u := &User{Email: "a@example.dk", Name: "Andreas", PasswordHash: "x"}
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
t.Fatalf("create user: %v", err)
}
if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil {
t.Fatalf("downgrade name: %v", err)
}
fixed, err := db.NormalizeUserNames(ctx)
if err != nil {
t.Fatalf("normalize: %v", err)
}
if fixed != 1 {
t.Fatalf("fixed = %d, want 1", fixed)
}
got, _ := db.UserByID(ctx, u.ID)
if got.Name != "Andreas" {
t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas")
}
// Idempotent: nothing left to do on a second pass.
again, err := db.NormalizeUserNames(ctx)
if err != nil {
t.Fatalf("normalize again: %v", err)
}
if again != 0 {
t.Errorf("second pass fixed = %d, want 0", again)
}
}
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// NoteShare is one person's direct grant on a note: who they are, and what they
// may do. The person is carried whole rather than by id, because every caller of
// this wants to show them — a name and an avatar colour, never a bare id.
type NoteShare struct {
User Person `json:"user"`
Role Role `json:"role"`
}
// ShareNote grants a person a role on a note, or changes the role if they already
// had one. The owner is passed so the row records who did the sharing; the caller
// has already checked that they may.
//
// Sharing a note with its own owner is refused: the owner already has everything a
// share could give, and a row saying otherwise is a contradiction waiting to
// confuse the access check.
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
if !role.Valid() || role == RoleOwner {
return errors.New("store: a note can be shared as viewer or editor")
}
var createdBy sql.NullString
if err := db.QueryRowContext(ctx,
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if createdBy.String == userID {
return errors.New("store: a note is already the owner's")
}
_, err := db.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, userID, role, byUserID, time.Now().Unix())
return err
}
// UnshareNote removes a person's grant. Removing one that is not there is not an
// error: the end state the caller asked for — this person cannot see this note —
// is the same either way.
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
_, err := db.ExecContext(ctx,
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
return err
}
// ListNoteShares is who a note is shared with, each with their role, ordered by
// name so the list under a note does not reshuffle every time it is opened.
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id, u.name, u.avatar_color, s.role
FROM note_shares s
JOIN users u ON u.id = s.user_id
WHERE s.note_id = ?
ORDER BY lower(u.name)`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NoteShare
for rows.Next() {
var s NoteShare
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// NoteShareRole is the role a person holds on a note through a direct share, if
// any. The bool is whether a share exists at all — distinct from a share whose
// role could not be read — so a caller does not have to treat "no share" as an
// error.
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
var role Role
err := db.QueryRowContext(ctx,
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
if !role.Valid() {
// A role the database holds but this build does not understand is no access,
// never a guess — the same rule project roles follow.
return "", false, nil
}
return role, true, nil
}
// NotesSharedWith returns the ids, among those given, that are shared directly with
// the user. It answers the Delt med mig question in one query rather than one per
// note, and takes the ids it is scoping so it never has to load a person's whole
// world to mark a single page.
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
out := map[string]bool{}
if len(noteIDs) == 0 {
return out, nil
}
args := make([]any, 0, len(noteIDs)+1)
args = append(args, userID)
for _, id := range noteIDs {
args = append(args, id)
}
rows, err := db.QueryContext(ctx,
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = true
}
return out, rows.Err()
}
+138 -84
View File
@@ -3,7 +3,9 @@ package store
import (
"context"
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
"time"
)
@@ -35,6 +37,17 @@ type Note struct {
// set by a caller: it is derived, and a caller that set it would be describing
// a note whose text says something else.
Links []NoteLink `json:"links,omitempty"`
// SharedWithMe is true when the person reading the list reached this note
// through a direct share rather than by owning it or being in its project. It
// is what puts the note in the Delt med mig group and shows whose note it is.
// Derived per request, so it is never stored and never set by a caller.
SharedWithMe bool `json:"shared_with_me,omitempty"`
// Owner is who the note belongs to — its created_by — as a name and a colour,
// for the chip on a note shared with the reader. Set only when it helps the
// list say something; nil the rest of the time.
Owner *Person `json:"owner,omitempty"`
}
// NoteLink is one reference from a note to something else.
@@ -89,6 +102,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 +139,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 +213,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
}
@@ -243,8 +274,9 @@ func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Not
UNION
SELECT project_id FROM project_members WHERE user_id = ?
)
OR notes.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID, userID)
}
// NotesLinking answers the backwards question: what points at this thing.
@@ -263,99 +295,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 +478,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 +506,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 +532,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
}
+11
View File
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
// but it also has bad days — forgiving a few failures keeps a flaky network from
// silently unsubscribing somebody's phone.
// MarkPushDelivered records that a push went through: the failure count goes back
// to zero and last_used_at moves forward. Without it a subscription that failed a
// few times and then recovered would keep those failures on its record and be one
// flaky night away from the ten that delete it — even though it is working.
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
_, err := db.ExecContext(ctx,
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
time.Now().Unix(), endpoint)
return err
}
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
if permanent {
// Not scoped by user, and that is right here: this is the push service
+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)
}
}
+109
View File
@@ -0,0 +1,109 @@
package store
import (
"context"
"errors"
"testing"
)
// One mail, one task — however many readers there are.
//
// Nothing could answer "has this already become a task?" before, so a second
// reader of the same mailbox made a second task. That is not hypothetical: a
// briefing run produced "Betal faktura fra Browns" twice in one pass, and a
// separate assistant reading the same inbox does the same thing every sweep.
func TestOneMailBecomesOneTask(t *testing.T) {
db := openTest(t)
ctx := context.Background()
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
t.Fatal(err)
}
inbox := newProject(t, db, user.ID)
const thread = "gmail:thread:18f2cabc"
first := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
if err := db.CreateTask(ctx, first, nil); err != nil {
t.Fatalf("first task: %v", err)
}
// The same thread again — a rerun, or somebody else's assistant.
second := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
err := db.CreateTask(ctx, second, nil)
if !errors.Is(err, ErrDuplicate) {
t.Fatalf("second task for the same thread: err = %v, want ErrDuplicate", err)
}
// And the question that makes the other agent safe.
id, err := db.TaskIDBySource(ctx, user.ID, thread)
if err != nil {
t.Fatal(err)
}
if id != first.ID {
t.Errorf("TaskIDBySource = %q, want %q", id, first.ID)
}
if id, err := db.TaskIDBySource(ctx, user.ID, "gmail:thread:aldrig-set"); err != nil || id != "" {
t.Errorf("an unseen thread answered %q, %v", id, err)
}
}
// A task somebody typed has no source, and there can be any number of those.
// The index is partial for exactly this reason — without the WHERE clause the
// second hand-written task in the database would be refused.
func TestTasksWithoutASourceDoNotCollide(t *testing.T) {
db := openTest(t)
ctx := context.Background()
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
t.Fatal(err)
}
inbox := newProject(t, db, user.ID)
for i, name := range []string{"køb kaffe", "ring til Andreas", "hent pakken"} {
if err := db.CreateTask(ctx, &Task{ProjectID: inbox, Content: name, CreatedBy: user.ID}, nil); err != nil {
t.Fatalf("task %d (%s): %v", i, name, err)
}
}
if id, err := db.TaskIDBySource(ctx, user.ID, ""); err != nil || id != "" {
t.Errorf("an empty source key answered %q, %v", id, err)
}
}
// Two people on one instance, each with their own mailbox. The same thread id
// reaching both of them is two tasks, correctly — the key is scoped to whoever
// the mail arrived for.
func TestTheSameThreadForTwoPeopleIsTwoTasks(t *testing.T) {
db := openTest(t)
ctx := context.Background()
var ids []string
for _, email := range []string{"kristian@example.dk", "andreas@example.dk"} {
u := &User{Email: email, Name: email, PasswordHash: "x"}
if err := db.CreateUser(ctx, u, "Indbakke"); err != nil {
t.Fatal(err)
}
inbox := newProject(t, db, u.ID)
task := &Task{ProjectID: inbox, Content: "Fælles tråd", CreatedBy: u.ID,
SourceKey: "gmail:thread:delt"}
if err := db.CreateTask(ctx, task, nil); err != nil {
t.Fatalf("%s: %v", email, err)
}
ids = append(ids, task.ID)
}
if ids[0] == ids[1] {
t.Error("the two people got the same task")
}
}
// A project to hang the tasks on. The tests are about the source key, not about
// where a task lives.
func newProject(t *testing.T, db *DB, ownerID string) string {
t.Helper()
p := &Project{OwnerID: ownerID, Name: "Indbakke"}
if err := db.CreateProject(context.Background(), p); err != nil {
t.Fatalf("create project: %v", err)
}
return p.ID
}
+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
+74 -8
View File
@@ -29,9 +29,19 @@ type Task struct {
AssigneeID string
CompletedAt *time.Time
CompletedBy string
// SnoozedUntil, when set and still in the future, parks the task at the bottom
// of its list, greyed, until it passes. Nil means the task is not snoozed.
SnoozedUntil *time.Time
// CreatedBy is empty when the author's account has been deleted: `created_by`
// is ON DELETE SET NULL so the task survives the person who wrote it.
CreatedBy string
// SourceKey names the thing out there this task was made from, namespaced:
// `gmail:thread:18f2c…`. Empty for a task somebody typed.
//
// It exists so that the second reader of a mailbox — another assistant, a
// rerun, a person — can ask "has this already become a task?" and get an
// answer. CreateTask returns ErrDuplicate rather than making a second one.
SourceKey string
SortOrder float64
// SubtaskCount and SubtaskDone are what a row can say without being opened:
// that there is something underneath it, and how much of it is left. Counted
@@ -212,7 +222,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,` + taskCounts + `
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE ` + strings.Join(where, " AND ") + `
@@ -306,7 +316,7 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,`+taskCounts+`
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
if err != nil {
return nil, err
@@ -383,7 +393,32 @@ func order(f TaskFilter) string {
if f.CompletedOnly {
return "t.completed_at DESC, t.id DESC"
}
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
// A snoozed task sinks below everything else — the CASE is 1 while it is still
// snoozed and 0 the moment its time passes, so it rejoins the list in its proper
// place by the clock, with nothing to run. Compared as an integer, or the TEXT
// strftime returns would sort under every number.
snoozed := "(CASE WHEN t.snoozed_until > CAST(strftime('%s','now') AS INTEGER) THEN 1 ELSE 0 END)"
return snoozed + ", t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
}
// SnoozeTask parks a task until a moment, or wakes it when until is nil. Only the
// snooze changes; the due date, the time, everything else is left alone — a snooze
// is about when you want to see it, not when it is due.
func (db *DB) SnoozeTask(ctx context.Context, id string, until *time.Time) error {
var v any
if until != nil {
v = until.Unix()
}
res, err := db.ExecContext(ctx,
`UPDATE tasks SET snoozed_until = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
v, time.Now().Unix(), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func scanTask(rows *sql.Rows) (Task, error) {
@@ -393,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
// stays; it loses its author.
var createdBy sql.NullString
var dueDatetime, completedAt sql.NullInt64
var duration sql.NullInt64
var duration, snoozedUntil sql.NullInt64
var created, updated int64
err := rows.Scan(&t.ID, &t.ProjectID, &sectionID, &parentID, &t.Content, &t.Description,
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
&recurrence, &assignee, &completedAt, &completedBy,
&createdBy, &t.SortOrder, &created, &updated,
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
if err != nil {
return t, err
@@ -425,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
v := int(duration.Int64)
t.DurationMin = &v
}
if snoozedUntil.Valid {
v := time.Unix(snoozedUntil.Int64, 0).UTC()
t.SnoozedUntil = &v
}
t.CreatedAt = time.Unix(created, 0).UTC()
t.UpdatedAt = time.Unix(updated, 0).UTC()
return t, nil
@@ -452,21 +491,48 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
_, err := tx.ExecContext(ctx, `
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
priority, due_date, due_datetime, due_timezone, duration_min,
recurrence_rule, assignee_id, created_by, sort_order,
recurrence_rule, assignee_id, created_by, source_key, sort_order,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
t.Content, t.Description, t.Priority, nullString(t.DueDate),
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
t.SortOrder, now.Unix(), now.Unix())
t.SourceKey, t.SortOrder, now.Unix(), now.Unix())
if err != nil {
// The one broken constraint that is not a bug: this source has already
// become a task. The caller skips it rather than making a duplicate,
// which is the entire reason the index exists.
if isUniqueViolation(err) {
return ErrDuplicate
}
return err
}
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
})
}
// TaskIDBySource answers "has this mail already become a task?" — the question
// nothing could answer before, and the one that stops two assistants reading the
// same inbox from making two of everything. Empty id and no error means no.
//
// Deleted tasks count. A task that was made from a mail and then thrown away is
// an answer — remaking it on the next sweep would be the same duplicate wearing
// a different hat.
func (db *DB) TaskIDBySource(ctx context.Context, userID, sourceKey string) (string, error) {
if sourceKey == "" {
return "", nil
}
var id string
err := db.QueryRowContext(ctx,
`SELECT id FROM tasks WHERE created_by = ? AND source_key = ?`,
userID, sourceKey).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return id, err
}
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
var max sql.NullFloat64
var err error
+142 -3
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"strings"
"time"
"unicode"
"github.com/kristianwind/verdande/internal/auth"
)
@@ -16,6 +17,11 @@ var (
ErrNotFound = errors.New("store: not found")
ErrEmailInUse = errors.New("store: that email address already has an account")
ErrInviteInvalid = errors.New("store: invite is not valid")
// ErrDuplicate er en unik nøgle, der blev brudt, oversat til noget en handler
// kan spørge om. Drivereren udstiller ikke sin egen fejltype brugbart, og en
// handler, der matcher på tekst, er en handler, der går i stykker den dag
// driveren omformulerer sig.
ErrDuplicate = errors.New("store: already exists")
)
type User struct {
@@ -57,6 +63,26 @@ func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// NormalizeName trims a display name and capitalises the first letter of every
// word, leaving the rest of each word exactly as typed. So "andreas" becomes
// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already
// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the
// first letter of a word is ever raised, never lowered.
//
// A name is the person's to spell; the one thing the instance insists on is that
// it does not start in lower case, because a column of lower-case first names
// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so
// "æblegaard" and "óscar" are raised the same as an ASCII initial.
func NormalizeName(name string) string {
fields := strings.Fields(name)
for i, f := range fields {
r := []rune(f)
r[0] = unicode.ToUpper(r[0])
fields[i] = string(r)
}
return strings.Join(fields, " ")
}
// CreateUser inserts a user and gives them an Inbox, in one transaction.
//
// The Inbox is created here rather than lazily on first use because every other
@@ -65,6 +91,7 @@ func NormalizeEmail(email string) string {
// of them end up racing to create the second one.
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
u.Email = NormalizeEmail(u.Email)
u.Name = NormalizeName(u.Name)
if u.ID == "" {
u.ID = NewID()
}
@@ -200,9 +227,50 @@ func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections [
// Person is the public half of a user: what somebody else is allowed to see when
// their name has to appear next to a task. No address, no hash, no timezone.
type Person struct {
ID string
Name string
AvatarColor string
ID string `json:"id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
}
// PersonByID resolves one user to a name and colour, or ErrNotFound. Used to check
// that somebody a note is about to be shared with actually exists, without loading
// the whole account.
func (db *DB) PersonByID(ctx context.Context, id string) (Person, error) {
var p Person
err := db.QueryRowContext(ctx,
`SELECT id, name, avatar_color FROM users WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.AvatarColor)
if errors.Is(err, sql.ErrNoRows) {
return Person{}, ErrNotFound
}
return p, err
}
// UsersForSharing is everyone on the instance except the caller — the people a note
// can be handed to.
//
// Deliberately wider than ListPeople, which is the project collaborators. The whole
// point of sharing a note directly is to do it without first standing up a shared
// project, so the picker cannot be limited to people you already share a project
// with. An instance's accounts are a closed, invited set to begin with — there is
// no open signup — so listing them to each other is not disclosure, it is the
// address book the feature needs.
func (db *DB) UsersForSharing(ctx context.Context, meID string) ([]Person, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, name, avatar_color FROM users WHERE id <> ? ORDER BY lower(name)`, meID)
if err != nil {
return nil, err
}
defer rows.Close()
people := []Person{}
for rows.Next() {
var p Person
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
return nil, err
}
people = append(people, p)
}
return people, rows.Err()
}
// PeopleByIDs looks up several users at once.
@@ -427,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
// invites were sent to, and changing it is a re-verification flow rather than a
// field. Neither is is_admin — an account cannot promote itself.
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
name = NormalizeName(name)
res, err := db.ExecContext(ctx,
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
name, timezone, locale, time.Now().Unix(), userID)
@@ -439,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
return nil
}
// NormalizeUserNames capitalises the first letter of every existing user's name,
// for the accounts created before the app started doing it on the way in.
//
// Idempotent: a name already normalised is left untouched, so it costs one pass
// of names at each start and writes nothing once it has caught up. Like SealNotes
// it runs at boot rather than as a migration, because the rule lives in Go —
// Unicode-aware and per word — and SQLite's own UPPER cannot spell it.
//
// `updated_at` is not touched: fixing the spelling of a name the person chose is
// the instance tidying up after itself, not the person editing their profile.
func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) {
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
if err != nil {
return 0, err
}
type pending struct{ id, name string }
var todo []pending
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
rows.Close()
return 0, err
}
if n := NormalizeName(name); n != name {
todo = append(todo, pending{id, n})
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(todo) == 0 {
return 0, nil
}
err = db.Tx(ctx, func(tx *sql.Tx) error {
for _, p := range todo {
if _, err := tx.ExecContext(ctx,
`UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, err
}
return len(todo), nil
}
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
// Sealed, like a mailbox password and for the same reason: the copies, not the
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
@@ -461,6 +580,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
return err
}
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
// fresh. It answers false when the step has already been spent — the same code, or
// an earlier one, being presented a second time.
//
// The guard is in the statement, not in a read-then-write: two logins racing with
// the same stolen code must not both find the old value and both proceed. The row
// moves forward only when the new step is strictly greater, so exactly one of them
// updates a row and the other sees nothing change.
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
res, err := db.ExecContext(ctx,
`UPDATE users SET totp_last_step = ? WHERE id = ?
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
step, userID, step)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// InboxID returns the user's Inbox project.
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
var id string
+4 -2
View File
@@ -44,16 +44,18 @@ extra_css:
nav:
- Home: index.md
- Setting it up:
- Self-hosting: self-hosting.md
- As a Rune: rune.md
- With Docker: self-hosting.md
- Configuration: configuration.md
- Using it:
- Signing in: signing-in.md
- Quick add: quick-add.md
- Notes: notes.md
- Filters: filters.md
- Importing from Todoist: import.md
- Connecting things:
- Calendar and CalDAV: caldav.md
- Calendar: calendar.md
- Feeds and CalDAV: caldav.md
- Mail to task: mail.md
- Mailboxes: mailboxes.md
- Claude (MCP): mcp.md
+26 -1
View File
@@ -15,12 +15,37 @@ gameskill:
# this: a manifest with new fields and an unchanged version is a manifest the
# panel has no reason to re-read, so the fields never appear and the failure is
# silent — the server's settings page simply does not offer them.
version: 4
# Bumpet til 6: v5 flyttede imaget til vores egen Gitea, og det virkede ikke.
# En rune opdaterer ikke sig selv — panelet skriver rune-rækken fra en
# UI-handling, så ændringen her kræver **Runes → verdande → Update**, og
# katalog-listen caches ti minutter, så to forsøg lige efter hinanden kan begge
# give den gamle version.
version: 6
icon: "app"
# The image is distroless — no shell — so keep_entrypoint runs the binary the
# image already declares as its ENTRYPOINT, and startup.exec passes it argv
# directly rather than through /bin/sh, which does not exist in there.
# GHCR, ikke vores egen Gitea. v5 prøvede det sidste og tog instansen ned.
#
# Panelets `PullImage` sætter aldrig `RegistryAuth`, så registret SKAL kunne
# læses uden legitimation. GHCR kan: dens token-endepunkt svarer 200 og udsteder
# en pull-token til hvem som helst. Gitea på gitea.nolimit.dk kan ikke —
# `/v2/token?scope=repository:kw/verdande:pull` svarer 401, også efter at repoet
# blev gjort offentligt. Instansen kræver login på alt, registret inklusive, og
# det er en indstilling for hele instansen frem for for denne pakke.
#
# Sådan ser det ud, når det går galt, for det tog en time at finde: 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, og udadtil er
# det en 502. Fejlen nævner aldrig, at det var et login, der manglede.
#
# Sådan afgør man det, uden at blive narret: spørg registrets token-endepunkt
# med `curl` og se efter en 200 med et token i. Test det ALDRIG med
# `docker --config <tom-mappe> pull` — en tom config stopper ikke maskinens
# credential-helper, så pullet bliver autentificeret uden at se sådan ud, og
# svaret er præcis det modsatte af sandheden.
docker:
image: "ghcr.io/kristianwind/verdande:latest"
data_path: /data
+77 -26
View File
@@ -856,8 +856,11 @@
</p>
<div class="actions">
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
Self-host it
<a class="button primary" href="https://kristianwind.github.io/verdande/rune/">
As a Rune
</a>
<a class="button" href="https://kristianwind.github.io/verdande/self-hosting/">
With Docker
</a>
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
@@ -928,13 +931,33 @@
<div class="shots">
<figure class="shot">
<img
src="screenshots/board.webp"
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
src="screenshots/calendar-light.webp"
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Board view — one column per section.</figcaption>
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/notes-shared-light.webp"
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/board.webp"
alt="A project in board view: one column per section, with task cards showing due dates, labels, an arrow to whoever a task is delegated to, and a snoozed card greyed at the foot of its column."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Board view — one column per section. A delegated card carries an arrow to whoever has it; a snoozed one greys at the foot.</figcaption>
</figure>
<figure class="shot">
<img
@@ -989,12 +1012,12 @@
<figure class="shot">
<img
src="screenshots/delegated.webp"
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them, each row carrying an arrow to that person."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Waiting on others — grouped by who has it.</figcaption>
<figcaption>Waiting on others — grouped by who has it, each marked with an arrow to them.</figcaption>
</figure>
<figure class="shot">
<img
@@ -1050,8 +1073,8 @@
<h3>Repeats, and says so in words</h3>
<p>
Recurring tasks are RRULE underneath and "every other Tuesday" on the
screen. Reminders are absolute or relative, and go out to the devices
you have allowed them on.
screen. Reminders are absolute or relative, and go out as Web Push to
an iPhone or a Mac you have allowed them on.
</p>
</div>
<div class="cell">
@@ -1063,12 +1086,38 @@
written about it. Archive what is done with it; the bin is for mistakes.
</p>
</div>
<div class="cell">
<h3>Share a note, or a page</h3>
<p>
Hand a single note to a person — to read or to edit — without standing up
a project for it. Notes other people share with you gather under
<em>Shared with me</em>, each marked with whose it is. File it in a project
instead and everyone who can read the project reads the note.
</p>
</div>
<div class="cell">
<h3>How long it takes</h3>
<p>
A task can carry a length, and the calendar draws it to scale. Drag the
foot of a task to make it longer or shorter, or drag its body to another
day or time — the length comes along.
</p>
</div>
<div class="cell">
<h3>Waiting on others</h3>
<p>
What you have handed to somebody else, on its own page, grouped under
the person who has it. The thing a shared list stops being able to tell
you the moment it is shared.
the person who has it — and marked where you meet it too, with an arrow
out of your hands, so a list reads at a glance as which tasks are yours
and which you are waiting on.
</p>
</div>
<div class="cell">
<h3>Snooze it out of the way</h3>
<p>
Park a task to this evening, tomorrow, next week, or a moment you pick.
It greys and sinks to the bottom of the list until then, and comes back
by itself — without touching when it is actually due.
</p>
</div>
</div>
@@ -1188,6 +1237,23 @@
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.41</span>
A task you have delegated is marked where you meet it, not only on the
"Waiting on others" page. A task can be snoozed to the bottom of the list
until a moment you choose, and comes back by itself.
</li>
<li>
<span class="ver">0.41</span>
Web Push reminders reach an iPhone and a Mac again, and every dated task
now shows up in a calendar you have subscribed to — not only the timed ones.
</li>
<li>
<span class="ver">0.41</span>
The calendar draws each task to its length: drag the foot to resize, or the
body to move it. A single note can be shared with one person, and a
<code>[[note]]</code> is a link you follow inline.
</li>
<li>
<span class="ver">0.34</span>
Sections can be dragged into the order the work actually happens in. The
@@ -1199,21 +1265,6 @@
or drag in, a source view, and a copy button on code blocks. Search
stopped answering by date and started answering by relevance.
</li>
<li>
<span class="ver">0.32</span>
A calendar of its own as a menu item, with a read-only Google calendar
that can be laid over it.
</li>
<li>
<span class="ver">0.31</span>
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
task — not only Gmail.
</li>
<li>
<span class="ver">0.30</span>
An installation count that says exactly what it sends, and can be turned
off in one click.
</li>
</ul>
</section>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+553 -16
View File
@@ -86,6 +86,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
expect(trouble).toEqual([]);
});
test('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
// takes. The link is made at display time — the stored title is still plain text.
await box.fill('læs https://example.com/artikel i dag');
await box.press('Enter');
const row = page.locator('.row').filter({ hasText: 'læs' });
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
await expect(link).toHaveAttribute('rel', /noopener/);
// Clicking the plain part of the title still opens the task; only the link is
// carved out. Aimed at the top-left, over "læs", not the URL in the middle.
await row.locator('.content').click({ position: { x: 3, y: 3 } });
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
@@ -127,6 +150,32 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
expect(trouble).toEqual([]);
});
test('en opgave kan slumres og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// "i dag" is parsed as the due date, so the task is named "Alfa" and shows here.
const box = page.getByLabel('Ny opgave');
await box.fill('Alfa i dag');
await box.press('Enter');
// Snooze it to tomorrow from its drawer.
await page.getByText('Alfa', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByRole('button', { name: 'I morgen' }).click();
// The row greys — it carries the snoozed class and a "slumret til" mark.
const alfa = page.locator('.row', { hasText: 'Alfa' });
await expect(alfa).toHaveClass(/snoozed/);
await expect(alfa.locator('.snooze-mark')).toBeVisible();
// The drawer now offers to wake it, and waking takes the grey off.
await drawer.getByRole('button', { name: 'Væk nu' }).click();
await expect(alfa).not.toHaveClass(/snoozed/);
expect(trouble).toEqual([]);
});
test('en opgave kan åbnes og redigeres', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
@@ -558,15 +607,35 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
// browser is pinned to Europe/Copenhagen and this process is not, and for two
// hours of every day they disagree about what today is.
await page.goto('/upcoming');
// Taller than the default window, and it is not cosmetic: the month grid is
// six rows, and in 720 px the last one is cut off by the fold. A synthetic
// drag drops at the target's centre without the auto-scroll a real pointer
// gets from the browser — so a target below the fold is never hit.
//
// That made this test depend on the date. The task sits on tomorrow, and when
// tomorrow is a Sunday, the day after is the first cell of the next row, down
// in the cut-off part. It passed for months and failed the morning tomorrow
// happened to be a Sunday, on a commit that had touched nothing near a
// calendar.
await page.setViewportSize({ width: 1280, height: 1000 });
await page.getByRole('button', { name: 'Måned', exact: true }).click();
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
await expect(chip).toBeVisible();
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
.toISOString()
.slice(0, 10);
// The neighbouring cell is taken from the grid, not worked out from the date.
// The day after tomorrow is not always in the month on screen — at the end of
// a short month it falls outside it entirely — and the cell next door always
// is. Falls back to the cell before, for the one day a month when there is no
// cell after.
const dates = await page
.locator('.day[data-date]')
.evaluateAll((cells) => cells.map((cell) => cell.dataset.date));
const at = dates.indexOf(from);
const to = dates[at + 1] ?? dates[at - 1];
await chip.dragTo(page.locator(`[data-date="${to}"]`));
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
@@ -856,6 +925,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
// Typed in lower case on purpose: the instance capitalises a name on the way
// in, so from here on this person is "Andreas" everywhere they are shown.
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
@@ -874,7 +945,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await page.reload();
await projectAction(page, /Delt/);
await expect(page.getByText('Ejer')).toBeVisible();
await page.getByLabel('Rolle for andreas').selectOption('editor');
await page.getByLabel('Rolle for Andreas').selectOption('editor');
await andreas.reload();
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
@@ -887,11 +958,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('brænde kaffe', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' });
await page.keyboard.press('Escape');
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible();
// And not on a task that is nobody's.
await page.getByLabel('Ny opgave').fill('min egen');
await page.getByLabel('Ny opgave').press('Enter');
@@ -1182,8 +1253,18 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
await expect(band.getByText('vande blomster')).toBeHidden();
// The events stayed where they were; a rescheduled task moves nothing else.
await expect(cell.locator('.tevent')).toHaveCount(1);
await expect(band.locator('.event')).toHaveCount(1);
//
// Named, not counted. A raw `.tevent` count here assumes this day holds nothing
// but this test's own events — and it does not: the suite shares one database,
// so an earlier test that drags a task across a month boundary can leave a timed
// task on this very day, and a timed task is a `.tevent` too. Counting them all
// made this pass alone and fail in the full run, on whatever date the arithmetic
// happened to land the two tests on the same cell. Asserting the meeting stayed
// and the task left is the thing the test actually means, and it is true however
// full somebody else left the day.
await expect(cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' })).toHaveCount(1);
await expect(cell.locator('.tevent').filter({ hasText: 'vande blomster' })).toHaveCount(0);
await expect(band.locator('.event').filter({ hasText: 'Feriedag' })).toHaveCount(1);
expect(trouble).toEqual([]);
});
@@ -1195,6 +1276,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
* thing this view was before anything was laid over it — and a Google account that
* is not there must not take it down with it.
*/
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// A timed task today at ten, half an hour long. Today, so the week opens on it
// without paging, and ten is inside the hours the grid draws by default.
const today = await page.evaluate(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const id = await page.evaluate(async (day) => {
const r = await fetch('/api/v1/tasks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
content: 'Møde med Anders',
due_date: day,
due_time: '10:00',
duration_min: 30
})
});
return (await r.json()).id;
}, today);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const chip = page
.locator(`[data-date="${today}"] .tevent.task`)
.filter({ hasText: 'Møde med Anders' });
await expect(chip).toBeVisible();
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
// Drag the foot of the task down a good stretch. It should grow, both on screen
// and in what the server stores — a resize is a change of length, not of time.
const box = await chip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
await page.mouse.up();
await expect
.poll(async () =>
page.evaluate(async (tid) => {
const r = await fetch('/api/v1/tasks/' + tid, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).duration_min;
}, id)
)
.toBeGreaterThan(30);
// And the chip is taller than it was, so the length is something you can see.
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
// The time did not move: it is still a ten o'clock task.
await expect(chip.locator('.at')).toHaveText('10:00');
expect(trouble).toEqual([]);
});
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/kalender');
@@ -2410,8 +2554,9 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Shared by being filed in a project. Not an access list of its own.
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
// Filing it in a project — the controls live in the Del popover in the footer.
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
// And it is really there, not merely labelled: the project's own page shows it.
@@ -2421,7 +2566,8 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
await expect(page.getByText('din igen')).toBeVisible();
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
@@ -2430,6 +2576,68 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p
expect(trouble).toEqual([]);
});
test('en note kan deles med en person direkte, og dukker op hos dem', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
// Somebody to share with: a second account on the instance, made the ordinary
// way. The candidate list is drawn when the note opens, so they must exist
// before it does.
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('sofie@example.dk');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const sofieCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const sofie = await sofieCtx.newPage();
await sofie.goto(link);
await sofie.getByLabel('Navn', { exact: true }).fill('Sofie');
await sofie.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await sofie.getByRole('button', { name: 'Opret konto' }).click();
await expect(sofie.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// The owner writes a note and hands it to Sofie, as a reader.
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Ferieplan for hele holdet');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Sharing lives in a popover behind the Del button in the footer.
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
await share.getByRole('button', { name: 'Tilføj' }).click();
// She is on the note now, with the role she was given.
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
await expect(row).toBeVisible();
await expect(row.locator('select')).toHaveValue('viewer');
// And it reaches her: her own notes list grows a Delt med mig group with it in.
//
// 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 — a bare "/noter" would land her logged out.
const noterURL = new URL('/noter', link).href;
await sofie.goto(noterURL);
await expect(sofie.getByRole('button', { name: /Delt med mig/ })).toBeVisible();
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeVisible();
// A viewer's note carries no star or archive control — it is not hers to put away.
const sofieRow = sofie.locator('.notes li').filter({ hasText: 'Ferieplan for hele holdet' });
await expect(sofieRow.locator('.star')).toHaveCount(0);
// Taken back, it leaves her list.
await row.getByRole('button', { name: 'Fjern adgang' }).click();
await expect(row).toBeHidden();
await sofie.goto(noterURL);
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeHidden();
await sofieCtx.close();
expect(trouble).toEqual([]);
});
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
page
}) => {
@@ -2467,6 +2675,47 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
expect(trouble).toEqual([]);
});
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// One note to point at, then another to write the link in.
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Rejseplan til Berlin');
await ed.blur();
await page.waitForTimeout(600);
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await page.keyboard.type('Mine planer');
await page.keyboard.press('Enter');
await page.keyboard.type('Se ');
await page.keyboard.type('[[Rejse');
// The picker offers the note by title, and choosing it turns the text into a
// real inline link — the title, not the brackets.
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
await expect(option).toBeVisible();
await option.click();
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
await expect(inline).toBeVisible();
// It survives the round-trip through Markdown and a reload.
await ed.blur();
await page.waitForTimeout(900);
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
// And it is a link you can follow: clicking it opens the note it names.
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
expect(trouble).toEqual([]);
});
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
@@ -2890,10 +3139,11 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
await page.keyboard.type('Tagsten og lægter');
await page.waitForTimeout(1200);
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
// nogen har valgt det bagefter.
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
await page.locator('.sharewrap > button').click();
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
expect(shared).toContain('Tagprojekt');
// Og den står på projektets egen side.
@@ -2959,6 +3209,96 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page })
expect(trouble).toEqual([]);
});
test('en note kan arkiveres fra sin egen fod, og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Kvartalsplan');
await ed.blur();
await page.waitForTimeout(700);
// The Arkivér button in the note's footer puts it away: it leaves the list.
await page.locator('footer .actions .button', { hasText: 'Arkivér' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// It is in the archive, where the same button now offers to bring it back.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await page.locator('.notes button.row', { hasText: 'Kvartalsplan' }).click();
await page.locator('footer .actions .button', { hasText: 'Tag frem igen' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// And back in the ordinary list.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Titel');
await page.keyboard.press('Enter');
// En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere
// monospace. Før rettelsen holdt en <pre> på markøren, og alt derefter blev ved
// med at være kode.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('kode linje');
await page.keyboard.press('Enter');
await page.keyboard.type('almindelig tekst');
// Koden står i sin blok; teksten efter står uden for den.
await expect(ed.locator('pre')).toHaveText('kode linje');
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('linje et');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('linje to');
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
// lagde et <p> inde i <pre> og ikke kom nogen vegne.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
// Den blok er nu brødtekst, ikke kode: den står som <p>, ikke i en <pre>.
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
await ed.blur();
await page.waitForTimeout(1200);
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
expect(trouble).toEqual([]);
});
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
@@ -3032,7 +3372,8 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
await expect(page.getByText('2 markeret')).toBeVisible();
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
// The selection bar's Arkivér, not the open note's own in the footer.
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
await page.waitForTimeout(900);
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
@@ -3303,3 +3644,199 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
expect(trouble).toEqual([]);
});
/**
* Notelisten står i grupper.
*
* Den føltes tæt, og 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.
*
* Datoen er flyttet op i en overskrift, favoritterne har fået deres egen gruppe —
* de lå allerede først, der stod bare ikke hvorfor — og arkivet grupperes på samme
* måde, for det er dér, de tolv hundrede importerede ligger.
*/
test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
for (const title of ['Gruppe én', 'Gruppe to']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type(title);
await page.keyboard.press('Enter');
await page.keyboard.type('Brødtekst i ' + title);
await expect(ed.locator('h1')).toHaveText(title);
await page.waitForTimeout(800);
}
await page.reload();
// Skrevet i dag, så de står under dagens overskrift.
const today = page.getByRole('heading', { name: /^I dag/ });
await expect(today, 'der er ingen datogruppe').toBeVisible();
// En favorit får sin egen gruppe. Uden overskriften ligger den bare først, og
// der står ikke hvorfor.
await expect(page.getByRole('heading', { name: /Favoritter/i })).toBeHidden();
const row = page.locator('li').filter({ hasText: 'Gruppe én' }).first();
await row.hover();
await row.getByRole('button', { name: /favorit/i }).click();
const favourites = page.getByRole('heading', { name: /Favoritter/i });
await expect(favourites).toBeVisible();
// Og den ligger under den, ikke under dagen.
const inFavourites = page
.locator('h3.group', { hasText: /Favoritter/i })
.locator('+ ul li')
.filter({ hasText: 'Gruppe én' });
await expect(inFavourites).toBeVisible();
// Grupper kan foldes sammen. Ikke husket på tværs af besøg: en gruppe, der
// åbner foldet uden at nogen bad om det, er en liste, der ser tom ud.
await favourites.getByRole('button').click();
await expect(inFavourites).toBeHidden();
await favourites.getByRole('button').click();
await expect(inFavourites).toBeVisible();
// En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
// matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget
// andet end det, der blev spurgt om.
await page.getByPlaceholder('Søg i noter').fill('Brødtekst');
await expect(page.locator('li').filter({ hasText: 'Gruppe to' }).first()).toBeVisible();
await expect(page.locator('h3.group')).toHaveCount(0);
await page.getByPlaceholder('Søg i noter').fill('');
// Rækkerne skal kunne *ses*, ikke bare findes.
//
// 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: alle
// overskrifter stod med deres tal, og der var ikke en eneste note under nogen af
// dem. Med tre grupper og en høj skærm var der plads nok til at det ikke skete,
// hvilket er hvorfor prøven herover ikke fangede det.
//
// Et lavt vindue fremtvinger presset. Tolv hundrede noter i tyve måneder gør det
// samme i virkeligheden.
await page.setViewportSize({ width: 1100, height: 380 });
await page.waitForTimeout(300);
await expect(
page.locator('li').filter({ hasText: 'Gruppe to' }).first(),
'rækkerne blev klippet væk, da der ikke var plads'
).toBeVisible();
// Og rulningen ligger på kassen om grupperne, ikke på hver liste for sig.
const scroller = await page
.locator('.list')
.evaluate((el) => getComputedStyle(el).overflowY);
expect(scroller, 'kassen om grupperne ruller ikke').toBe('auto');
const inner = await page
.locator('.list ul')
.first()
.evaluate((el) => getComputedStyle(el).overflowY);
expect(inner, 'den enkelte liste ruller stadig for sig').toBe('visible');
await page.setViewportSize({ width: 1280, height: 720 });
// Arkivet grupperes på samme måde. Det er dér, det betyder mest.
await row.hover();
await row.getByRole('button', { name: /Arkivér/i }).click();
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await expect(
page.locator('h3.group'),
'arkivet står ugrupperet'
).not.toHaveCount(0);
await expect(page.locator('li').filter({ hasText: 'Gruppe én' }).first()).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* 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`. Den 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 — og en fjerde værdi ville
* kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads.
*/
test('et projekts kalender kan vises som en uge', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ugeprojekt');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible();
const box = page.getByLabel('Ny opgave');
await box.fill('male døren i dag kl 10');
await box.press('Enter');
await expect(page.getByText('male døren')).toBeVisible();
await page.getByRole('button', { name: 'Kalender', exact: true }).click();
await page.getByRole('button', { name: 'Uge', exact: true }).click();
// Døgnet, ikke en rude: opgaven står på sit klokkeslæt.
const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' });
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
await expect(placed).toContainText('10:00');
// Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der
// afgør det, og projektet er delt med andre skærme.
await page.reload();
await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible();
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await expect(page.locator('.weekgrid')).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* En gentagelse siges på læserens sprog, også dem serveren kun kan på dansk.
*
* `recurrence.Describe` skriver dansk og kan ikke bare tage en locale med:
* `toTaskJSON` har niogtyve kaldesteder, og mange er WebSocket-udsendelser, hvor én
* nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at skrive
* den i. Reglen er den samme uanset hvem der læser den, så beskrivelsen hører
* hjemme dér, hvor læseren er.
*
* De to her er valgt, fordi de *ikke* står på listen over de fem almindelige, som
* fladen altid har kunnet navngive. Før det her faldt de tilbage til serverens
* tekst — og den er dansk, hvad end kontoen er sat til.
*/
test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
for (const [text, says] of [
['vande planterne hver mandag', 'hver mandag'],
['tømme opvasker hverdage', 'hverdage']
]) {
const box = page.getByLabel('Ny opgave');
await box.fill(text);
await box.press('Enter');
await page.waitForTimeout(400);
// I indbakken og ikke på I dag: en ugentlig regel sætter den første
// forfaldsdag til den næste sådan dag, som sjældent er i dag.
await page
.getByRole('navigation', { name: 'Hovedmenu' })
.getByRole('link', { name: /^Indbakke/ })
.click();
const name = text.split(' ').slice(0, 2).join(' ');
const row = page.locator('.row', { hasText: name }).first();
await expect(row).toBeVisible();
await expect(
row.locator('.repeat'),
`"${text}" blev ikke læst som en gentagelse`
).toContainText(says);
await page.goto('/');
}
expect(trouble).toEqual([]);
});
+54
View File
@@ -73,6 +73,11 @@ const SHOTS = [
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
// The new work, and in the light theme, which is what most people want to look
// at a landing page in. The calendar drawing tasks to their length, and a note
// somebody shared into the "Shared with me" group.
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
// Scrolled, because the themes are the second panel on the page and a picture
@@ -114,6 +119,13 @@ async function showNoteSource(page) {
await page.waitForTimeout(600);
}
async function openSharedNote(page) {
// The note Mette shared in, so the "Shared with me" group and its owner chip are
// what the picture is of rather than something the group happens to sit above.
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
await page.waitForTimeout(600);
}
async function showThemes(page) {
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
// already a few pixels inside the viewport when the page loads, so "if needed"
@@ -340,6 +352,23 @@ async function handOver(ids) {
await api(`/tasks/${ids.detail}/comments`, {
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
});
// A note of Mette's own, handed to the owner — so their Notes page has a
// "Shared with me" group with a real note in it, marked as hers. This is the
// other half of sharing: not the project path, but a page passed to a person.
const note = await api('/notes', {
body: `# Packaging quotes
Three back, one still to come:
- **Nordfelt** — 79 øre a bag at 2,000, plates included
- **Kolding Karton** — 84 øre, faster, kinder about small runs
- Bruun still owes me a number
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
});
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
return { colleague: me.id };
}
@@ -373,6 +402,8 @@ async function seed({ colleague, noteTitle }) {
).padStart(2, '0')}`;
};
const me = await api('/auth/me', null, 'GET');
// Two headings over the projects, which is what the sidebar grew for.
const work = await api('/project-groups', { name: 'Work' });
const home = await api('/project-groups', { name: 'Home' });
@@ -420,6 +451,17 @@ async function seed({ colleague, noteTitle }) {
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
// A handful given a time and a length, so the calendar's week draws them to
// scale rather than as a row of identical blocks — a stand-up, a long
// planning block, a call, a review. Spread across the day and the days, so
// the picture is a week that has a shape.
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
// The next couple of weeks, so Upcoming and the month grid have something in
// them rather than a row of empty cells.
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
@@ -441,6 +483,17 @@ async function seed({ colleague, noteTitle }) {
const made = {};
for (const task of tasks) made[task.content] = await api('/tasks', task);
// One task snoozed, so the board and the list show a card parked at the bottom
// of its section — greyed, with a "snoozed until" mark — set aside rather than
// forgotten, and without touching when it is due.
const snoozeUntil = (() => {
const d = new Date();
d.setDate(d.getDate() + 2);
d.setHours(9, 0, 0, 0);
return d.toISOString();
})();
await api(`/tasks/${made['Write the job advert'].id}/snooze`, { until: snoozeUntil });
// A saved filter, because the sidebar shows them and an empty heading reads as
// a feature that does not work.
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
@@ -560,6 +613,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
await api('/users', { email: 'anders@example.dk' });
return {
owner: me.id,
work: launch.id,
detail: detail.id,
invite: invite.link,
+11
View File
@@ -310,6 +310,7 @@ export const api = {
completeTask: (id) => post(`/tasks/${id}/complete`),
reopenTask: (id) => post(`/tasks/${id}/reopen`),
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
@@ -383,6 +384,10 @@ export const api = {
// an event, only which calendars to look at.
getCalendar: () => get('/calendar'),
setCalendars: (shown) => put('/calendar/calendars', { shown }),
// En kalender, man kun har en adresse til. Flere pr. person, hvor Google er
// én: adressen er identiteten, som tokenet er det for OAuth.
subscribeCalendar: (url, name) => post('/calendar/subscriptions', { url, name }),
unsubscribeCalendar: (id) => del(`/calendar/subscriptions/${id}`),
disconnectCalendar: () => del('/calendar'),
authorizeCalendar: () => post('/calendar/authorize'),
syncCalendar: () => post('/calendar/sync'),
@@ -401,6 +406,12 @@ export const api = {
deleteNote: (id) => del(`/notes/${id}`),
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
// Sharing a note with people directly — its own access list, beside the project
// path. Owner only; the list call also returns who it can still be shared with.
noteShares: (id) => get(`/notes/${id}/shares`),
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
// there is no instance-wide registration behind these the way Gmail has one.
mailboxes: () => get('/mailboxes'),

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