Compare commits

...
56 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 982c3aab18 test(i18n): commit the guard that was written and never added
Release / Build and publish image (push) Failing after 10m6s
It was created alongside the three untranslated strings it catches and left
untracked — `git add -u` stages tracked files only. CI has been running without
it since.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:35:36 +02:00
153 changed files with 18173 additions and 1733 deletions
+11 -5
View File
@@ -79,12 +79,18 @@ jobs:
working-directory: web
run: npm ci --no-audit --no-fund
# Chromium only. A smoke test exists to catch a build that does not work at
# all; running it three times in three engines finds the same breakage three
# times and costs three times as much.
- name: Install Chromium
# Chromium for the suite, WebKit for one test.
#
# A smoke test exists to catch a build that does not work at all, and running
# the whole thing in three engines finds the same breakage three times for
# three times the cost. Focus is the exception, and it took a real report to
# learn it: Safari ignores `autofocus` on anything inserted after the page has
# loaded, so every inline form in the app opened with no cursor in it. WebKit
# here is the same engine Safari is, and `webkit-forms` in the Playwright
# config greps a single test out of the suite to run in it.
- name: Install browsers
working-directory: web
run: npx playwright install --with-deps chromium
run: npx playwright install --with-deps chromium webkit
# The suite builds the frontend, embeds it and starts the real binary — see
# playwright.config.js. There is nothing to build here first.
+15 -1
View File
@@ -6,7 +6,9 @@ on:
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
permissions:
contents: read
# write rather than read: the job creates the GitHub Release the update notice
# reads. packages: write is for pushing the image.
contents: write
packages: write
jobs:
@@ -50,3 +52,15 @@ jobs:
VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
# A tag is not a release. The in-app update notice reads GitHub's
# releases/latest, which answers 404 for a repository that only has tags —
# so without this step the check can never find anything, however correctly
# it is switched on.
#
# Created after the image, so a release never announces a version that
# failed to build.
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
make_latest: true
+15
View File
@@ -11,6 +11,16 @@
# Frontend
node_modules/
# iCloud syncs ~/Documents, and it evicts what it thinks is cold — which for a
# dependency tree is nearly all of it. An evicted file blocks on read until it is
# downloaded again, and a download that stalls hangs the build for good: 0 % CPU,
# no output, forever. macOS never syncs anything ending in .nosync, so the tree
# lives there and `node_modules` is a symlink to it. Redo it after a fresh clone:
#
# mv node_modules node_modules.nosync && ln -s node_modules.nosync node_modules
#
# The real fix is a repo path outside a synced folder.
node_modules.nosync/
/web/.svelte-kit/
/web/build/
@@ -45,3 +55,8 @@ node_modules/
# the process is killed, which during a hung build is exactly what happens. One
# was committed by accident.
/web/vite.config.js.timestamp-*.mjs
# A compiled `go build ./tools/shots` in the repo root. It was committed once by
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
# it right back.
/shots
+20 -3
View File
@@ -5,7 +5,17 @@
# manager and no libc — the attack surface of the running container is the binary.
# --- 1. the web interface ----------------------------------------------------
FROM node:22-alpine AS web
#
# Pinned to the *build* platform, not the target. What this stage produces is
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
# on — so building it once and copying it into both architectures is not an
# optimisation, it is the correct thing.
#
# Without the pin, buildx runs this stage once per target, which means running
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
# instruction the emulator does not implement, roughly one release in three. It
# looks like a broken build and is a broken emulator.
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src
COPY web/ ./
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
fi
# --- 2. the binary -----------------------------------------------------------
FROM golang:1.26-alpine AS build
#
# Also pinned to the build platform, and cross-compiled instead. Go does that
# natively and CGO is already off, so there is nothing to emulate — where
# building *in* the target architecture means running the whole Go toolchain
# under QEMU for one of the two.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src
@@ -37,11 +52,13 @@ COPY . .
COPY --from=web /src/build ./cmd/verdande/webbuild
ARG VERSION=dev
# Supplied by buildx per target. The default keeps a plain `docker build` working.
ARG TARGETARCH=amd64
# CGO off is what makes this binary static, and it is only possible because the
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
RUN CGO_ENABLED=0 GOOS=linux go build \
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-tags embedweb \
-trimpath \
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
+27 -4
View File
@@ -34,9 +34,13 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
@regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views.
- **Sharing.** Internal users are created by an admin; anyone else joins through an
invite link sent to their email. Owner, editor and viewer roles.
views, and foldable groups over the projects in the sidebar.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
everybody arrives through an invite link, either to a project or — from
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
by somebody else.
- **Live sync.** A change made by somebody else appears without a refresh.
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
@@ -60,12 +64,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project roles and permission checks | Done, tested |
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | Done, tested |
| Assignee shown on every task that is somebody else's | Done, smoke-tested |
| Activity log, visible per project | Done, smoke-tested |
| Change a member's role without removing them | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
| Task detail: description, sub-tasks, comments, files, reminders | Done |
| Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested |
| User administration: invite to the instance, promote, remove | Done, tested |
| Server errors kept in the database, readable after a restart | Done, tested |
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
| Invite and password-reset links that open the right page | Done, smoke-tested |
| Drag to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested |
| Foldable project groups in the sidebar | Done, tested |
| Month view on Upcoming, not only per project | Done, smoke-tested |
| Recurring tasks (RRULE, Danish + English) | Done, tested |
| Reminders, absolute and relative | Done, tested |
| Saved filters with a query language | Done, tested |
@@ -102,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. |
| ![A week on Upcoming](site/screenshots/week.png) | ![A task, opened](site/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
| ![The five themes](site/screenshots/themes.png) | ![Instance history](site/screenshots/history.png) |
| Five themes, all measured for contrast. | What has been done on the server. |
Regenerate these with `go run ./tools/shots` against a running instance.
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
the real binary, creates its own account, fills it with demo data and photographs
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
still works, and is the way to photograph an instance that is already running.)
## Running it
+5 -1
View File
@@ -77,7 +77,11 @@ func run() error {
errc := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Addr)
// The version is repeated here rather than only on the "starting" line
// above: this is the line the Rune's done_regex matches, so it is the one
// sitting at the bottom of the panel console after a deploy — which is
// exactly when somebody wants to know what actually started.
log.Info("listening", "addr", cfg.Addr, "version", version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
+6
View File
@@ -52,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
| `forbidden` | 403 | Cross-site request, or admin needed. |
| `not_found` | 404 | Does not exist, or you cannot see it. |
| `conflict` | 409 | Already exists, or the state does not allow it. |
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
| `ai_not_configured` | 409 | No AI provider has been set up. |
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
| `validation_failed` | 422 | Check `fields`. |
| `rate_limited` | 429 | Too many attempts. |
| `internal_error` | 500 | Our fault. |
@@ -96,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
### Views, filters and labels
+3 -1
View File
@@ -21,7 +21,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views.
board and calendar views, and foldable groups over the projects in the sidebar.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Live sync** — a change made by somebody else appears without a refresh.
+1069 -6
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

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

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+1 -5
View File
@@ -7,11 +7,7 @@ import { test as setup, expect } from '@playwright/test';
* or the app shell is broken, nothing below runs and the failure names this file.
*/
export const USER = {
name: 'Kristian',
email: '[email protected]',
password: 'et langt kodeord til test'
};
import { USER } from './user.js';
const AUTH_FILE = 'e2e/.auth/user.json';
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
/**
* The account every test runs as.
*
* In its own file rather than exported from auth.setup.js, because Playwright
* refuses to let one test file import another — and both the setup that creates
* this account and the device-list test that signs in as it a second time need
* the same credentials.
*/
export const USER = {
name: 'Kristian',
email: '[email protected]',
password: 'et langt kodeord til test'
};

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