Compare commits
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -50,3 +60,9 @@ node_modules/
|
||||
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
|
||||
# it right back.
|
||||
/shots
|
||||
|
||||
# The working handover. It names the panel, the server id and the production
|
||||
# address — a map of one person's setup, which does not belong in a public repo.
|
||||
# Kept on disk because it is how a session hands over to the next one.
|
||||
HANDOFF.md
|
||||
NOTES-PLAN.md
|
||||
|
||||
@@ -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}" \
|
||||
|
||||
@@ -37,8 +37,10 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
||||
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.** Internal users are created by an admin; anyone else joins through an
|
||||
invite link sent to their email. Owner, editor and viewer roles.
|
||||
- **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
|
||||
@@ -63,12 +65,18 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| 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 |
|
||||
@@ -110,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Board view — one column per section. | The light theme. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
|
||||
|  |  |
|
||||
| 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
|
||||
|
||||
@@ -182,6 +197,9 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
||||
|
||||
## What lives in `/data`
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/httpapi"
|
||||
"github.com/kristianwind/verdande/internal/jobs"
|
||||
"github.com/kristianwind/verdande/internal/secret"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,15 @@ func run() error {
|
||||
defer db.Close()
|
||||
log.Info("database ready", "path", db.Path())
|
||||
|
||||
// Before anything reads a setting. The key lives outside the database on
|
||||
// purpose: backups can be downloaded through the interface, so a token stored
|
||||
// in the clear leaves with every copy anyone takes.
|
||||
box, err := secret.Open(cfg.SecretKey, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db.UseSecrets(box)
|
||||
|
||||
httpapi.Version = version
|
||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||
|
||||
@@ -64,6 +74,8 @@ func run() error {
|
||||
defer stopJobs()
|
||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.Push = api.PushToUser
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -55,11 +55,57 @@ One registration serves every user on the instance; each person authorises their
|
||||
own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
||||
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
|
||||
|
||||
## Secrets
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_SECRET_KEY` | — | Encrypts the mail tokens and mailbox passwords in the database. 32 bytes of base64. |
|
||||
|
||||
Mail tokens and [mailbox](mailboxes.md) passwords are encrypted at rest, and the
|
||||
key does not live in the database. That is the whole point: **backups can be
|
||||
downloaded through the interface**, so a copy that ends up somewhere it should
|
||||
not would otherwise open your mail.
|
||||
|
||||
Leave the variable empty and verdande writes a key to `secret.key` in the data
|
||||
directory on first start, mode `0600`. Backups are a `VACUUM INTO` of the
|
||||
database alone, so the key file is not in them.
|
||||
|
||||
Generate one yourself if you would rather keep it out of the volume entirely:
|
||||
|
||||
```
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
!!! warning "Lose the key and the mailboxes must be reconnected"
|
||||
Nothing else is lost — tasks, projects, files and history are not encrypted —
|
||||
but a token that cannot be decrypted cannot be used, and there is no way back
|
||||
from a key that is gone. If you restore a database somewhere new, bring
|
||||
`secret.key` with it, or set `VERDANDE_SECRET_KEY` to the same value.
|
||||
|
||||
A database written before there was a key is read as it stands and encrypted the
|
||||
next time each value is written, so turning this on costs nobody a reconnection.
|
||||
|
||||
## Update checks
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
|
||||
All three together let **Settings → Notifications** restart this instance,
|
||||
which is also how it picks up a new version: a container cannot replace its own
|
||||
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
||||
the panel for a *scheduled* restart rather than an immediate one — an immediate
|
||||
one is carried out while the request is still open, and stopping the container
|
||||
kills the very process waiting for the answer, so the panel never gets to the
|
||||
half that starts it again. Any one of the
|
||||
three alone does nothing, and the page names the ones that are missing.
|
||||
|
||||
The token is a panel credential with control over that server. Anybody who can
|
||||
read this environment can restart it — which is true of anybody with the host
|
||||
anyway — and it is never sent to a browser.
|
||||
|
||||
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
||||
at most every six hours and sends nothing about your instance — no identifier, no
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Mailboxes
|
||||
|
||||
Star a mail and it becomes a task.
|
||||
|
||||
This is the other direction from [mail to task](mail.md): instead of forwarding
|
||||
something, Verdande reads a mailbox you connect and makes a task out of anything
|
||||
you have flagged. Nothing else in the mailbox is touched, and nothing is ever
|
||||
sent.
|
||||
|
||||
Each mailbox belongs to the person who connected it. Two people on one instance
|
||||
connect their own and see only their own — there is no shared inbox and no
|
||||
administrator who can read yours.
|
||||
|
||||
## Gmail
|
||||
|
||||
**Settings → Integrations → Gmail → Connect.**
|
||||
|
||||
Gmail needs an OAuth client, which is the instance's registration with Google
|
||||
rather than anybody's account — see [Configuration](configuration.md#gmail).
|
||||
Once it is set up, each person connects their own mailbox through it.
|
||||
|
||||
Choose what makes a task:
|
||||
|
||||
| Trigger | What it reads |
|
||||
|---|---|
|
||||
| Starred | Anything you have starred |
|
||||
| Label | Anything carrying a label you name |
|
||||
|
||||
!!! note "The app stays Internal if your client is Internal"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace. A personal `@gmail.com` address will be
|
||||
refused with `org_internal`, and that is the client working as configured.
|
||||
Switching to *External* means refresh tokens that expire every seven days
|
||||
until Google has verified the app — for one personal account, forwarding to
|
||||
your [mail-to-task address](mail.md) is the shorter road.
|
||||
|
||||
## iCloud, Fastmail and anything else over IMAP
|
||||
|
||||
**Settings → Integrations → Mailboxes → Add a mailbox.**
|
||||
|
||||
You need three things:
|
||||
|
||||
| Field | Example |
|
||||
|---|---|
|
||||
| IMAP server | `imap.mail.me.com:993` |
|
||||
| Username | your address |
|
||||
| App password | not your ordinary password — see below |
|
||||
|
||||
The connection is tested before it is saved. A mailbox that cannot be read is
|
||||
refused while you are still looking at the form, rather than accepted and then
|
||||
failing quietly every ten minutes.
|
||||
|
||||
Flag a message — the flag, not unread — and it becomes a task on the next sweep.
|
||||
Unread is a state you are still using yourself, and a sync that consumed it would
|
||||
be taking something away.
|
||||
|
||||
### App passwords
|
||||
|
||||
Every host worth naming issues a separate password for applications, which you
|
||||
can withdraw on its own without changing the one you sign in with. Use that.
|
||||
|
||||
- **iCloud** — [appleid.apple.com](https://appleid.apple.com) → Sign-In and
|
||||
Security → App-Specific Passwords
|
||||
- **Fastmail** — Settings → Privacy & Security → Integrations → New app password
|
||||
|
||||
The password is [encrypted at rest](configuration.md#secrets), and it is never
|
||||
returned by the API once saved.
|
||||
|
||||
### Office 365 does not work here
|
||||
|
||||
Microsoft has closed IMAP to passwords; it wants its own OAuth flow. There is no
|
||||
app password that will get you in, and a mailbox pointed at `outlook.office365.com`
|
||||
will be refused. Forward to your [mail-to-task address](mail.md) instead.
|
||||
|
||||
## How often
|
||||
|
||||
Every ten minutes, in the background. **Fetch now** does it immediately, which is
|
||||
for when you have just connected something and want to see it work.
|
||||
|
||||
A run reads at most twenty-five messages, so a mailbox with years of flagged mail
|
||||
in it fills up over several sweeps rather than in one.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
**Disconnect** on the mailbox. The tasks it made stay — they are your work now,
|
||||
not the mailbox's. Nothing is read after that, and the stored credential is
|
||||
deleted rather than merely disabled.
|
||||
@@ -70,7 +70,22 @@ an error message and a lost thought.
|
||||
write it, and guessing month-first would silently move a date by months rather
|
||||
than failing visibly.
|
||||
|
||||
## The shortcuts
|
||||
## Just start typing
|
||||
|
||||
Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for
|
||||
Upcoming, ++cmd+k++ to search, and ++question++ for the full list.
|
||||
There is no shortcut to remember. Press a letter anywhere in the app and you are
|
||||
writing a task, with the letter you pressed already in the field. The thought does
|
||||
not have to survive a journey to find somewhere to put it.
|
||||
|
||||
It only fires when nothing else has focus — a key pressed at a field, a button or
|
||||
a link belongs to that thing.
|
||||
|
||||
Under the field is a line saying what the parser reads: `#` project, `/` section,
|
||||
`@` label, `p1` priority, and an example date. It appears while you are typing,
|
||||
which is the one moment a placeholder cannot help you.
|
||||
|
||||
++cmd+k++ opens the palette — search, and navigation to anywhere in the app.
|
||||
|
||||
!!! note "The single letters used to navigate"
|
||||
++t++ for Today and ++u++ for Upcoming are gone. One keyboard cannot both
|
||||
spell *tal med Anders* and jump to Today on the same ++t++, and capturing is
|
||||
the thing done fifty times a day. The palette does the rest.
|
||||
|
||||
|
After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,39 @@
|
||||
# Signing in
|
||||
|
||||
Three ways in, and you can have all of them at once.
|
||||
|
||||
## A password
|
||||
|
||||
What the first account is created with. Nothing is emailed, nothing is registered
|
||||
anywhere: the first person to open a fresh instance creates the administrator
|
||||
account, and after that people arrive by invite link.
|
||||
|
||||
## A code on top of it
|
||||
|
||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||
app reads the QR code. Once it is on, the password alone is not enough.
|
||||
|
||||
Recovery codes are shown once, when you turn it on. They are the way back if the
|
||||
phone is gone, and they are not shown again.
|
||||
|
||||
## A passkey
|
||||
|
||||
**Settings → Account → Passkeys → Add a passkey.**
|
||||
|
||||
A key on a device instead of a secret in your head. The private half never leaves
|
||||
the device — a phone's secure element, a laptop's keychain, a hardware key — and
|
||||
the signature is bound to the address it was made for, so a convincing copy of the
|
||||
sign-in page at a different address gets nothing.
|
||||
|
||||
Give each one a name you will recognise later. *min bærbare* is what makes a list
|
||||
of keys reviewable, and a list nobody can read is a list nobody revokes from.
|
||||
|
||||
Passkeys need the address in `VERDANDE_BASE_URL` to be a hostname. Browsers refuse
|
||||
to make one for a bare IP address, so an instance reached at `http://192.168.1.10:8080`
|
||||
will show the passkey section as unavailable rather than failing at the moment
|
||||
somebody tries.
|
||||
|
||||
!!! note "One is enough, two is better"
|
||||
Lose the only device holding your only passkey and you are back to the
|
||||
password. Keep the password, or register a second key — a phone as well as a
|
||||
laptop.
|
||||
@@ -3,21 +3,33 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.55.0
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -2,12 +2,33 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -18,6 +39,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
@@ -25,20 +48,60 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// The relying party is derived from the instance's own base URL, and the two ways
|
||||
// that goes wrong are both silent until a browser refuses.
|
||||
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
base string
|
||||
ok bool
|
||||
why string
|
||||
}{
|
||||
{"https://verdande.example.dk", true, "a real domain"},
|
||||
{"http://localhost:5173", true, "localhost, which every browser excepts"},
|
||||
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
|
||||
|
||||
// An IP address is not a domain. The browser throws SecurityError long
|
||||
// after the server has happily issued a challenge, so it is refused here
|
||||
// and the endpoints answer 503 instead.
|
||||
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
|
||||
{"http://[::1]:8080", false, "an IPv6 literal"},
|
||||
{"", false, "no address at all"},
|
||||
{"not a url", false, "not an address"},
|
||||
} {
|
||||
_, err := NewWebAuthn(c.base, "verdande")
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// Passkeys, wrapped so the rest of the app never touches the protocol types.
|
||||
//
|
||||
// A password and a TOTP code are both shared secrets: the server keeps something
|
||||
// that can be stolen from it, and the person keeps something that can be talked
|
||||
// out of them. A passkey is neither. The private half never leaves the device, and
|
||||
// the signature is bound to the origin that asked for it — so a convincing copy of
|
||||
// the sign-in page at another address gets a signature it cannot use.
|
||||
//
|
||||
// That binding is also the one thing that can be got wrong here, and it is
|
||||
// invisible when it is: the RP ID has to be the registrable domain of the address
|
||||
// people actually visit. Get it wrong and registration works, login works on your
|
||||
// machine, and everybody behind a different hostname is quietly locked out.
|
||||
|
||||
// WebAuthn is the relying party — this instance, as an authenticator sees it.
|
||||
type WebAuthn struct{ *webauthn.WebAuthn }
|
||||
|
||||
// NewWebAuthn derives the relying party from the instance's own base URL.
|
||||
//
|
||||
// Derived rather than configured, because a second setting that has to agree with
|
||||
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
|
||||
// the failure is a login that cannot be completed, reported as "the button does
|
||||
// nothing".
|
||||
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
|
||||
}
|
||||
|
||||
// The RP ID is the host without the port. A port in it is rejected by every
|
||||
// browser, and localhost:5173 during development is exactly where somebody
|
||||
// would first put one.
|
||||
rpID := parsed.Hostname()
|
||||
|
||||
// An IP address is not a domain, and no browser will accept one as an RP ID —
|
||||
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
|
||||
// the server has happily issued a challenge. Refused here instead, so the
|
||||
// endpoints answer 503 and the interface never offers a button that cannot
|
||||
// work. `localhost` is the exception every browser makes, and it is what
|
||||
// development runs on.
|
||||
if ip := net.ParseIP(rpID); ip != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"auth: passkeys need a domain name, and %q is an IP address — "+
|
||||
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
|
||||
}
|
||||
|
||||
w, err := webauthn.New(&webauthn.Config{
|
||||
RPID: rpID,
|
||||
RPDisplayName: displayName,
|
||||
// The origin is the full scheme://host:port, which is what the browser
|
||||
// sends and what the signature is checked against.
|
||||
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: passkeys: %w", err)
|
||||
}
|
||||
return &WebAuthn{w}, nil
|
||||
}
|
||||
|
||||
// A PasskeyUser is what the library needs to know about an account.
|
||||
//
|
||||
// The handle is the account id rather than anything a person chose. It is written
|
||||
// into the credential on the device and cannot be changed afterwards, so it must
|
||||
// not be an email address: changing your email would orphan every key you own.
|
||||
type PasskeyUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
|
||||
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
|
||||
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
|
||||
|
||||
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
|
||||
// go in a BLOB well enough, but the id is also compared against what the browser
|
||||
// sends back as base64url, and one encoding on both sides is one thing to be
|
||||
// wrong about.
|
||||
func EncodeCredentialID(raw []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func DecodeCredentialID(encoded string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(encoded)
|
||||
}
|
||||
@@ -33,11 +33,41 @@ type Config struct {
|
||||
GmailClientID string
|
||||
GmailClientSecret string
|
||||
|
||||
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
|
||||
// Empty means a key file beside the data — see internal/secret.
|
||||
SecretKey string
|
||||
|
||||
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
||||
// for: a self-hosted app that reaches out without being told to has broken
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
// A container cannot replace its own image from the inside. Restarting is the
|
||||
// panel's job — it recreates the container and pulls `:latest` on the way — so
|
||||
// what this needs is a way to ask. All three are required together; any one
|
||||
// alone cannot do anything.
|
||||
//
|
||||
// The token is a panel credential with control over that server. Anybody who
|
||||
// can read this environment can restart it, which is true of anybody with the
|
||||
// host anyway. It is never sent to a browser: the settings page is told only
|
||||
// whether it is set.
|
||||
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
|
||||
// front of this server. Configurable so a test can prove the budget is applied
|
||||
// without spending the real one on every run.
|
||||
GmailSyncBudget time.Duration
|
||||
|
||||
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
|
||||
// in production, where it means Google.
|
||||
GmailTokenURL string
|
||||
GmailAPIURL string
|
||||
|
||||
PanelURL string
|
||||
PanelToken string
|
||||
PanelServerID string
|
||||
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
@@ -69,7 +99,12 @@ func Load() (*Config, error) {
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
|
||||
@@ -25,20 +25,50 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
tokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
apiBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
)
|
||||
|
||||
// Endpoints is where Google is.
|
||||
//
|
||||
// A field rather than a constant so a test can point it at a server it controls.
|
||||
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
||||
// and "the sync comes back before whatever sits in front of this server gives up
|
||||
// on it" is exactly the kind of promise worth a test and impossible to make
|
||||
// against the real thing.
|
||||
//
|
||||
// The zero value is Google, so nothing outside a test has to know it exists.
|
||||
type Endpoints struct {
|
||||
Token string
|
||||
API string
|
||||
}
|
||||
|
||||
func (e Endpoints) token() string {
|
||||
if e.Token != "" {
|
||||
return e.Token
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
func (e Endpoints) api() string {
|
||||
if e.API != "" {
|
||||
return e.API
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
||||
// the instance rather than to a person: one registration, any number of users.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
// Endpoints is Google unless a test says otherwise.
|
||||
Endpoints Endpoints
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
@@ -128,7 +158,7 @@ func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error)
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
@@ -171,6 +201,7 @@ func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
endpoints Endpoints
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
@@ -178,6 +209,12 @@ func NewClient(accessToken string) *Client {
|
||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(e Endpoints) *Client {
|
||||
c.endpoints = e
|
||||
return c
|
||||
}
|
||||
|
||||
// Message is the part of a Gmail message a task is made from.
|
||||
type Message struct {
|
||||
ID string
|
||||
@@ -196,7 +233,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
||||
var parsed struct {
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.EmailAddress, nil
|
||||
@@ -220,7 +257,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -251,7 +288,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,91 @@ 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"`
|
||||
NavOrder []string `json:"nav_order"`
|
||||
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{}
|
||||
}
|
||||
order := u.NavOrder
|
||||
if order == nil {
|
||||
order = []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, NavOrder: order,
|
||||
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)
|
||||
}
|
||||
|
||||
// handleSetNavOrder records the order of the fixed views in the sidebar.
|
||||
//
|
||||
// Its own route for the same reason as the folds: this writes on every drop, and
|
||||
// sharing the profile's path would make a reorder send a name and a timezone it
|
||||
// was never asked to change.
|
||||
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Order []string `json:"order"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.Order) > 20 {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
|
||||
s.storeError(w, r, "set nav order", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -88,7 +164,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 +206,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 +286,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 +339,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 +353,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 +404,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 +417,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 +431,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 +440,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 +469,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 +507,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 +545,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 +553,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 +596,64 @@ 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.
|
||||
// upstream reports a failure that came from somebody else's service.
|
||||
//
|
||||
// Separate from `internal` because the cause is different and so is the fix: a 500
|
||||
// is this server broken, and this is Google — or whoever — saying no. The status
|
||||
// says so (502), and the message is passed through rather than replaced, because
|
||||
// "invalid_grant" or "insufficient authentication scopes" is the whole diagnosis
|
||||
// and a generic sentence in its place throws it away.
|
||||
//
|
||||
// It is recorded like a 500 for exactly the reason the error log exists: a Gmail
|
||||
// sync that has been failing for a week is invisible otherwise. The background job
|
||||
// writes a log line the next restart erases, and nobody is watching the one screen
|
||||
// that would have said so.
|
||||
// StatusUpstreamRefused is what this server answers when a service it depends on
|
||||
// says no. Not 502, though that is what it means: a proxy is entitled to replace
|
||||
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
|
||||
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
|
||||
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
|
||||
// "something went wrong". A 4xx passes through untouched, and the truth reaching
|
||||
// the person who has to act on it beats the tidier status code.
|
||||
const StatusUpstreamRefused = http.StatusUnprocessableEntity
|
||||
|
||||
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
|
||||
s.log.Warn(what, "err", err)
|
||||
|
||||
userID := ""
|
||||
if u := userFrom(r.Context()); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
s.db.RecordError(r.Context(), store.ServerError{
|
||||
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
|
||||
What: what, Message: err.Error(), UserID: userID,
|
||||
RequestID: middleware.GetReqID(r.Context()),
|
||||
})
|
||||
|
||||
writeError(w, StatusUpstreamRefused, code, err.Error())
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://127.0.0.1",
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -47,8 +47,28 @@ const (
|
||||
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"
|
||||
|
||||
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
|
||||
// "insufficient authentication scopes" — and they are useless behind a generic
|
||||
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
|
||||
// read "something went wrong", and had nowhere to go. The interface wraps the
|
||||
// message in one that says where it came from.
|
||||
CodeGmailFailed = "gmail_failed"
|
||||
|
||||
// And the same for a mailbox read over IMAP. A host's refusal is the whole
|
||||
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
||||
// is not there — and it says it in its own words.
|
||||
CodeMailboxFailed = "mailbox_failed"
|
||||
)
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +242,7 @@ 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})
|
||||
@@ -261,7 +261,7 @@ 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)
|
||||
@@ -271,7 +271,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,12 +11,58 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) gmailConfig() gmail.Config {
|
||||
return gmail.Config{
|
||||
// defaultGmailSyncBudget bounds one "fetch now" when nothing else says otherwise.
|
||||
// Well under the hundred seconds a proxy is willing to wait, so this server is the
|
||||
// one that answers a slow mailbox.
|
||||
const defaultGmailSyncBudget = 25 * time.Second
|
||||
|
||||
// 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.
|
||||
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||
return gmail.NewClient(accessToken).At(gmail.Endpoints{
|
||||
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
||||
cfg := gmail.Config{
|
||||
ClientID: s.cfg.GmailClientID,
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
Endpoints: endpoints,
|
||||
}
|
||||
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,7 +74,7 @@ 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, CodeGmailNotConfigured,
|
||||
@@ -37,13 +84,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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 +100,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 +130,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 +149,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)
|
||||
@@ -118,7 +165,10 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
||||
// Best effort: a mailbox that connects but cannot name itself is still
|
||||
// connected, and the status endpoint asks again when it finds no address —
|
||||
// so this failing is a delay rather than a dead end.
|
||||
email, err := s.gmailClient(token.AccessToken).Profile(r.Context())
|
||||
if err != nil {
|
||||
s.log.Warn("gmail profile", "err", err)
|
||||
}
|
||||
@@ -129,16 +179,28 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
delete(settings, "pkce_state")
|
||||
delete(settings, "pkce_expires")
|
||||
|
||||
settings["refresh_token"] = token.RefreshToken
|
||||
settings["access_token"] = token.AccessToken
|
||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||
settings["email"] = email
|
||||
if _, ok := settings["trigger"]; !ok {
|
||||
settings["trigger"] = "starred"
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, r, "clear gmail handshake", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, "save gmail tokens", err)
|
||||
// The mailbox itself, kept beside the ones read over IMAP. Connecting again
|
||||
// writes the same row: Gmail is one per person, because the OAuth flow is.
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "read gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
if box == nil {
|
||||
box = &store.Mailbox{UserID: user.ID, Kind: "gmail", Trigger: "starred"}
|
||||
}
|
||||
box.Name = email
|
||||
box.Username = email
|
||||
box.RefreshToken = token.RefreshToken
|
||||
box.AccessToken = token.AccessToken
|
||||
box.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
||||
s.internal(w, r, "save gmail tokens", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
||||
@@ -150,9 +212,36 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
created, err := s.SyncGmail(r.Context(), user)
|
||||
// A budget of its own, well under what sits in front of this server.
|
||||
//
|
||||
// The sync fetches up to twenty-five messages one at a time, each with its own
|
||||
// thirty-second timeout — so a slow mailbox can hold the request open for
|
||||
// minutes. Cloudflare gives up at a hundred seconds and answers with its own
|
||||
// 502 page, which is HTML: the browser gets no JSON, no error code and no
|
||||
// message, and shows "something went wrong". Nothing reaches the error log
|
||||
// either, because this handler never returned.
|
||||
//
|
||||
// Whatever was created by the deadline is kept: every task is committed as it
|
||||
// is made, so a short budget is a shorter run rather than a lost one. The
|
||||
// background job keeps the full one; it has nobody waiting.
|
||||
// An unset budget is not a budget of nothing: a zero here would expire the
|
||||
// context before the first call and make every sync a silent no-op.
|
||||
budget := s.cfg.GmailSyncBudget
|
||||
if budget <= 0 {
|
||||
budget = defaultGmailSyncBudget
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||
defer cancel()
|
||||
|
||||
created, err := s.SyncGmail(ctx, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Not a failure: the mailbox is slow or busy, some tasks were made, and
|
||||
// the rest arrive on the next run ten minutes from now.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
||||
@@ -163,48 +252,52 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
// Returns how many were made. Also used by the background job, which is why it
|
||||
// takes a context and a user rather than a request.
|
||||
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
settings, err := s.db.UserSettings(ctx, user.ID, "gmail")
|
||||
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
str := func(key string) string {
|
||||
v, _ := settings[key].(string)
|
||||
return v
|
||||
if box != nil {
|
||||
// The same guard as the mailboxes read over IMAP, for the same reason: a
|
||||
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||
// one marker.
|
||||
unlock := s.lockMailbox(box.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||
box = fresh
|
||||
}
|
||||
}
|
||||
|
||||
refresh := str("refresh_token")
|
||||
if refresh == "" {
|
||||
if box == nil || box.RefreshToken == "" {
|
||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
||||
}
|
||||
query := gmail.Query(str("trigger"), str("label"))
|
||||
query := gmail.Query(box.Trigger, box.Label)
|
||||
if query == "" {
|
||||
return 0, nil // connected but no trigger chosen
|
||||
}
|
||||
refresh := box.RefreshToken
|
||||
|
||||
// The access token is refreshed only when it has actually expired.
|
||||
access := str("access_token")
|
||||
expires, _ := settings["expires_at"].(float64)
|
||||
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||
token, err := s.gmailConfig().Refresh(ctx, refresh)
|
||||
access := box.AccessToken
|
||||
if access == "" || box.ExpiresAt.Before(time.Now()) {
|
||||
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
access = token.AccessToken
|
||||
settings["access_token"] = access
|
||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||
box.AccessToken = access
|
||||
box.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
client := gmail.NewClient(access)
|
||||
client := s.gmailClient(access)
|
||||
ids, err := client.List(ctx, query, 25)
|
||||
if err != nil {
|
||||
if err == gmail.ErrUnauthorized {
|
||||
// The person revoked access, or changed their password. Forget the
|
||||
// tokens rather than retrying every ten minutes forever.
|
||||
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
|
||||
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{})
|
||||
_ = s.db.DeleteMailbox(ctx, user.ID, box.ID)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
@@ -213,19 +306,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// rather than as a table: it is bounded by the query's own 30-day window, and
|
||||
// a table would be one more thing to migrate for a feature this small.
|
||||
seen := map[string]bool{}
|
||||
if raw, ok := settings["seen"].([]any); ok {
|
||||
for _, v := range raw {
|
||||
if id, ok := v.(string); ok {
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range box.Seen {
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
inbox, err := s.db.InboxID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
projectID := str("project_id")
|
||||
projectID := box.ProjectID
|
||||
if projectID == "" {
|
||||
projectID = inbox
|
||||
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||
@@ -237,6 +326,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
// A spent budget ends the run rather than turning into twenty-five failed
|
||||
// fetches logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
}
|
||||
msg, err := client.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||
@@ -270,15 +364,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
if created > 0 {
|
||||
// Only the ids Gmail still returns are kept, so the list cannot grow
|
||||
// without bound as messages fall out of the 30-day window.
|
||||
keep := make([]any, 0, len(ids))
|
||||
keep := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
keep = append(keep, id)
|
||||
}
|
||||
}
|
||||
settings["seen"] = keep
|
||||
settings["last_sync"] = time.Now().Unix()
|
||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||
box.Seen = keep
|
||||
box.LastSyncAt = time.Now()
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
return created, err
|
||||
}
|
||||
}
|
||||
@@ -300,3 +394,127 @@ 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)
|
||||
}
|
||||
|
||||
// gmailProfile fetches the mailbox address and stores it, refreshing the access
|
||||
// token first if it has expired.
|
||||
//
|
||||
// Shared by the connect callback and the status endpoint. The callback is the
|
||||
// natural place to learn the address, and it is also the one moment that cannot be
|
||||
// retried — so the status endpoint asks again when it finds nothing, and the answer
|
||||
// corrects itself on the next look.
|
||||
func (s *Server) gmailProfile(ctx context.Context, box *store.Mailbox) (string, error) {
|
||||
access := box.AccessToken
|
||||
if access == "" || box.ExpiresAt.Before(time.Now()) {
|
||||
token, err := s.gmailConfig(ctx).Refresh(ctx, box.RefreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
access = token.AccessToken
|
||||
box.AccessToken = access
|
||||
box.ExpiresAt = token.ExpiresAt
|
||||
}
|
||||
|
||||
email, err := s.gmailClient(access).Profile(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
box.Name = email
|
||||
box.Username = email
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// A Gmail failure has to survive being reported.
|
||||
//
|
||||
// It used to be hidden twice over. The handler answered 502 with the code
|
||||
// `internal_error`, which the interface maps to "something went wrong" — so
|
||||
// Google's own words, the only thing that says what to do, were replaced by a
|
||||
// sentence that says nothing. And `writeError` records nothing, so the error log
|
||||
// was empty: a person clicked Fetch now, read a generic failure, and there was
|
||||
// nowhere to look it up afterwards.
|
||||
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
// A Gmail that refuses, the way an expired grant does.
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Connected, with a refresh token that will be refused.
|
||||
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||
UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
||||
resp.StatusCode)
|
||||
}
|
||||
if body["code"] != "gmail_failed" {
|
||||
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
|
||||
"sentence, which throws the diagnosis away", body["code"])
|
||||
}
|
||||
if msg, _ := body["error"].(string); msg == "" {
|
||||
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
|
||||
}
|
||||
|
||||
// And it is in the error log, which is the point of having one: a sync that has
|
||||
// been failing for a week is invisible otherwise.
|
||||
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := logged["errors"].([]any)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["what"] != "sync gmail" {
|
||||
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
||||
}
|
||||
if row["status"] != float64(StatusUpstreamRefused) {
|
||||
t.Errorf("status = %v, want 502", row["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// A slow mailbox must not hold the request open until something in front of this
|
||||
// server gives up on it.
|
||||
//
|
||||
// This is what was actually happening in production: each Gmail call has its own
|
||||
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
|
||||
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
|
||||
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
|
||||
// showed "something went wrong". The handler had never returned.
|
||||
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
// A Gmail that never answers.
|
||||
blocked := make(chan struct{})
|
||||
defer close(blocked)
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
// would put half a minute into the suite to learn the same thing.
|
||||
cfg.GmailSyncBudget = time.Second
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A token that has not expired, so the run goes straight to the message list
|
||||
// rather than stopping at a refresh.
|
||||
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own client, because the shared one gives up after ten seconds — which is
|
||||
// shorter than the budget being tested and would prove nothing either way.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
|
||||
"its own, and whatever sits in front of this server answers for it",
|
||||
err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
took := time.Since(started)
|
||||
|
||||
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
|
||||
// bug, whatever status eventually arrives.
|
||||
// Comfortably over the one-second budget and far under any proxy's patience.
|
||||
if took > 10*time.Second {
|
||||
t.Errorf("the sync took %s; the budget is not being applied",
|
||||
took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ 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{
|
||||
@@ -49,11 +49,11 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
@@ -134,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
|
||||
}
|
||||
}
|
||||
@@ -145,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
|
||||
}
|
||||
|
||||
@@ -187,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
|
||||
@@ -216,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
|
||||
@@ -231,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)
|
||||
@@ -266,7 +266,7 @@ 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() {
|
||||
@@ -278,7 +278,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai split", "err", err)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,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))
|
||||
@@ -315,7 +315,7 @@ 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() {
|
||||
@@ -326,7 +326,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai summary", "err", err)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
||||
@@ -370,20 +370,39 @@ 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")
|
||||
user := userFrom(r.Context())
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
str := func(key string) string {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
if box == nil {
|
||||
writeJSON(w, http.StatusOK, gmailSettings{})
|
||||
return
|
||||
}
|
||||
|
||||
// The mailbox address, fetched again if it is missing.
|
||||
//
|
||||
// It is stored at connect time, and the call that fetches it can fail — a slow
|
||||
// token, a scope Google had not applied yet. That failure used to be a log line
|
||||
// and an empty string, so the page said "an unknown account" and went on saying
|
||||
// it forever: the only way back was to disconnect and reconnect, which nobody
|
||||
// would guess. Asking again here costs one request on a page nobody opens
|
||||
// often, and it means the wrong answer lasts until the next look.
|
||||
email := box.Username
|
||||
if email == "" && box.RefreshToken != "" {
|
||||
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
|
||||
email = fetched
|
||||
} else {
|
||||
s.log.Warn("gmail profile", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, gmailSettings{
|
||||
Connected: str("refresh_token") != "",
|
||||
Email: str("email"),
|
||||
Trigger: str("trigger"),
|
||||
Label: str("label"),
|
||||
Connected: box.RefreshToken != "",
|
||||
Email: email,
|
||||
Trigger: box.Trigger,
|
||||
Label: box.Label,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,27 +413,42 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
values["trigger"] = req.Trigger
|
||||
values["label"] = req.Label
|
||||
if box == nil {
|
||||
// Choosing what makes a task before connecting is allowed, and the choice is
|
||||
// kept: the row is the mailbox with no credential in it yet, which reads as
|
||||
// not connected everywhere that asks and is skipped by the sweep.
|
||||
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
|
||||
}
|
||||
box.Trigger = req.Trigger
|
||||
box.Label = req.Label
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
||||
s.internal(w, "save gmail settings", err)
|
||||
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
||||
s.internal(w, r, "save gmail settings", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDisconnectGmail forgets the connection, tokens and all.
|
||||
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
|
||||
// stay: they are the person's work now, not the mailbox's.
|
||||
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)
|
||||
user := userFrom(r.Context())
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
if box != nil {
|
||||
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
|
||||
s.internal(w, r, "disconnect gmail", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -1153,3 +1153,166 @@ func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||
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://localhost/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)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotating the mail-to-task address, which reported "something went wrong" in
|
||||
// production with nothing in the error log — the combination that means the 500 did
|
||||
// not come through `s.internal()`.
|
||||
func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, before := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get: status %d, body %v", resp.StatusCode, before)
|
||||
}
|
||||
first, _ := before["address"].(string)
|
||||
if first == "" {
|
||||
t.Fatal("no address to begin with")
|
||||
}
|
||||
|
||||
resp, after := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("rotate: status %d, body %v", resp.StatusCode, after)
|
||||
}
|
||||
second, _ := after["address"].(string)
|
||||
if second == "" || second == first {
|
||||
t.Errorf("the address did not change: %q → %q", first, second)
|
||||
}
|
||||
|
||||
// And twice in a row, which is what somebody does when the first one appeared
|
||||
// to fail.
|
||||
resp, again := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second rotate: status %d, body %v", resp.StatusCode, again)
|
||||
}
|
||||
if third, _ := again["address"].(string); third == second {
|
||||
t.Error("the second rotation returned the same address")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kristianwind/verdande/internal/imap"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// defaultMailboxSyncBudget bounds one read of one mailbox when somebody is waiting.
|
||||
// The same reasoning as Gmail's: a mailbox that has stopped answering must not
|
||||
// hold a request open until whatever sits in front of this server answers for it.
|
||||
const defaultMailboxSyncBudget = 25 * time.Second
|
||||
|
||||
func (s *Server) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
list, err := s.db.Mailboxes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list mailboxes", err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []store.Mailbox{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"mailboxes": list})
|
||||
}
|
||||
|
||||
// handleAddMailbox connects one, after proving it can actually be read.
|
||||
//
|
||||
// Tested before it is saved, not after: a mailbox that was accepted and then
|
||||
// silently fails every ten minutes is worse than one that refused at the door,
|
||||
// where the person is still holding the password they just typed.
|
||||
func (s *Server) handleAddMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body.Host = strings.TrimSpace(body.Host)
|
||||
body.Username = strings.TrimSpace(body.Username)
|
||||
if body.Host == "" || body.Username == "" || body.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, CodeValidation,
|
||||
"a mailbox needs a host, a username and a password")
|
||||
return
|
||||
}
|
||||
// A host with no port is the mistake everyone makes once, and 993 is the answer
|
||||
// for every host worth naming.
|
||||
if !strings.Contains(body.Host, ":") {
|
||||
body.Host += ":993"
|
||||
}
|
||||
|
||||
account := imap.Account{
|
||||
Host: body.Host, Username: body.Username,
|
||||
Password: body.Password, Folder: body.Folder,
|
||||
}
|
||||
client, err := imap.Dial(account)
|
||||
if err != nil {
|
||||
s.upstream(w, r, CodeMailboxFailed, "connect mailbox", err)
|
||||
return
|
||||
}
|
||||
client.Close()
|
||||
|
||||
user := userFrom(r.Context())
|
||||
m := &store.Mailbox{
|
||||
UserID: user.ID, Kind: "imap", Name: strings.TrimSpace(body.Name),
|
||||
Host: body.Host, Username: body.Username,
|
||||
Password: body.Password, Folder: body.Folder,
|
||||
}
|
||||
if m.Name == "" {
|
||||
m.Name = body.Username
|
||||
}
|
||||
if err := s.db.SaveMailbox(r.Context(), m); err != nil {
|
||||
// The unique index. Saying which mailbox is already here beats "constraint
|
||||
// failed", which is true and useless.
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
writeError(w, http.StatusConflict, CodeValidation,
|
||||
fmt.Sprintf("%s on %s is already connected", body.Username, body.Host))
|
||||
return
|
||||
}
|
||||
s.internal(w, r, "save mailbox", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.DeleteMailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID")); err != nil {
|
||||
s.internal(w, r, "delete mailbox", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSyncMailbox reads one mailbox now, within a budget of its own.
|
||||
func (s *Server) handleSyncMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
m, err := s.db.Mailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "read mailbox", err)
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such mailbox")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), defaultMailboxSyncBudget)
|
||||
defer cancel()
|
||||
|
||||
created, err := s.SyncMailbox(ctx, user, m)
|
||||
if err != nil {
|
||||
// Whatever was made by the deadline is kept: each task is committed as it
|
||||
// is made, so a short budget is a shorter run rather than a lost one.
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeMailboxFailed, "sync mailbox", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"created": created})
|
||||
}
|
||||
|
||||
// SyncMailbox turns the flagged mail newer than the last run into tasks.
|
||||
//
|
||||
// Exported because the background job calls it too, on the same terms and without
|
||||
// a budget: nobody is waiting there.
|
||||
func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mailbox) (int, error) {
|
||||
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
||||
// releasing before the marker is written would leave exactly the gap this is
|
||||
// here to close.
|
||||
unlock := s.lockMailbox(m.ID)
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock. The row that came in may have been fetched before the
|
||||
// run that just finished moved the marker.
|
||||
if fresh, err := s.db.Mailbox(ctx, m.UserID, m.ID); err == nil && fresh != nil {
|
||||
m = fresh
|
||||
}
|
||||
|
||||
client, err := imap.Dial(imap.Account{
|
||||
Host: m.Host, Username: m.Username, Password: m.Password, Folder: m.Folder,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
messages, searched, err := client.Since(m.LastUID, 25)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
projectID, err := s.db.InboxID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
for _, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
// below has to be written for it: a run that created tasks and forgot how
|
||||
// far it got would make them all again on the next pass.
|
||||
break
|
||||
}
|
||||
|
||||
subject := msg.Subject
|
||||
if subject == "" {
|
||||
subject = "(uden emne)"
|
||||
}
|
||||
content := subject
|
||||
if msg.From != "" {
|
||||
content = msg.From + ": " + subject
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID,
|
||||
Content: content,
|
||||
Description: msg.Snippet,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// A run that got all the way through moves the marker to the whole of what the
|
||||
// search considered, not merely to the last message it happened to read. That
|
||||
// is what stops the same mail becoming a task again on the next run when the
|
||||
// server did not put a uid in the fetch reply.
|
||||
if ctx.Err() == nil && searched > highest {
|
||||
highest = searched
|
||||
}
|
||||
|
||||
// Written even when the loop was cut short, and outside the request's context
|
||||
// so a cancelled request still records what it did.
|
||||
if highest > m.LastUID {
|
||||
if err := s.db.MarkMailboxRead(context.WithoutCancel(ctx), m.ID, highest, time.Now()); err != nil {
|
||||
s.log.Warn("mark mailbox read", "err", err, "mailbox", m.ID)
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// lockMailbox serialises one mailbox against itself and returns the release.
|
||||
func (s *Server) lockMailbox(id string) func() {
|
||||
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -154,7 +155,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")
|
||||
@@ -496,3 +497,168 @@ func clamp(value, fallback, max int) int {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// --- notes ---------------------------------------------------------------------
|
||||
|
||||
// registerNoteTools adds the note half of the MCP surface.
|
||||
//
|
||||
// Separate from the task tools for one reason worth stating: this is how a note
|
||||
// gets in from somewhere else. Apple Notes has no export worth the name, but a
|
||||
// model running on the machine can read Notes.app and write what it finds through
|
||||
// these — one note at a time, with the text as Markdown. That is a migration
|
||||
// nobody has to build a parser for.
|
||||
func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_notes",
|
||||
Description: "Find notes by text. Matches title and body, and is generous about " +
|
||||
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
|
||||
"With no query it returns the most recent notes. This is the tool for " +
|
||||
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("Words to look for in the title and body."),
|
||||
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
|
||||
}),
|
||||
}, s.mcpSearchNotes)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_note",
|
||||
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
|
||||
"also exactly what an export produces, so nothing is converted and nothing " +
|
||||
"is lost. Two things in the text are read out of it and become links: " +
|
||||
"`#Projekt` files the note against that project, so it appears on the " +
|
||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
||||
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||
}),
|
||||
}, s.mcpCreateNote)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_note",
|
||||
Description: "Change a note. Only the fields given are changed. Sending `body` " +
|
||||
"replaces the whole text, so read the note first if you mean to add to it — " +
|
||||
"and the links are read again from the new text, so a #tag removed is a link " +
|
||||
"removed.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"note_id": mcp.Str("Which note."),
|
||||
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||
}),
|
||||
}, s.mcpUpdateNote)
|
||||
}
|
||||
|
||||
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Text string `json:"text"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
limit := params.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
var notes []store.Note
|
||||
var err error
|
||||
if strings.TrimSpace(params.Text) == "" {
|
||||
notes, err = s.db.LooseNotes(ctx, userID)
|
||||
} else {
|
||||
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The same filter the HTTP surface applies, for the same reason: a search must
|
||||
// not become a way to read what you cannot open.
|
||||
out := make([]map[string]any, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if !s.mcpMayRead(ctx, &n, userID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": n.ID, "title": n.Title, "body": n.Body,
|
||||
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
|
||||
})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return map[string]any{"notes": out, "count": len(out)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == userID
|
||||
}
|
||||
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Body string `json:"body"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n := &store.Note{
|
||||
CreatedBy: userID,
|
||||
Body: params.Body,
|
||||
Pinned: params.Pinned,
|
||||
}
|
||||
if params.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ProjectID = params.ProjectID
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The links are reported back rather than left silent. A note whose #tag was
|
||||
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
|
||||
// the row that was written.
|
||||
return map[string]any{
|
||||
"id": n.ID, "title": n.Title, "links": n.Links,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n, err := s.db.Note(ctx, params.NoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == nil || !s.mcpMayRead(ctx, n, userID) {
|
||||
return nil, fmt.Errorf("no such note")
|
||||
}
|
||||
if n.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Body != nil {
|
||||
n.Body = *params.Body
|
||||
}
|
||||
if params.Pinned != nil {
|
||||
n.Pinned = *params.Pinned
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// Searching and listing are the same endpoint, because they answer the same
|
||||
// question with different words. ?q= searches; ?project= narrows; neither gives
|
||||
// the loose ones.
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
|
||||
found, err := s.db.SearchNotes(r.Context(), q, 50)
|
||||
if err != nil {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
return
|
||||
}
|
||||
|
||||
if projectID := r.URL.Query().Get("project"); projectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
|
||||
s.storeError(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
found, err := s.db.NotesInProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
return
|
||||
}
|
||||
|
||||
// Everything they can see, not only the loose ones. Filing a note in a project
|
||||
// is how it gets shared, and a list that dropped it at that moment made sharing
|
||||
// look like deleting.
|
||||
found, err := s.db.AllNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
}
|
||||
|
||||
// readable drops what this person may not see.
|
||||
//
|
||||
// Filtered after the search rather than inside it: FTS5 answers about text, and
|
||||
// teaching the index about roles would put an access rule in a place nobody would
|
||||
// think to look when it was wrong.
|
||||
func (s *Server) readable(r *http.Request, notes []store.Note) []store.Note {
|
||||
out := make([]store.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if s.mayTouchNote(r, &n, store.RoleViewer) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// The same answer for "no such note" and "not yours", so an id cannot be probed.
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
// No title. It is the note's first line and is derived on every save — a field
|
||||
// here would be a promise this server cannot keep.
|
||||
type noteBody struct {
|
||||
ProjectID *string `json:"project_id"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID}
|
||||
if body.ProjectID != nil && *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, user.ID, store.RoleEditor); err != nil {
|
||||
s.storeError(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
if body.Pinned != nil {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, n)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
s.storeError(w, r, "move note", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
if body.Pinned != nil {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteNote(r.Context(), n.ID); err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleNotesLinking answers the backwards question for a task or a project:
|
||||
// what has somebody written about this.
|
||||
func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
|
||||
kind := chi.URLParam(r, "kind")
|
||||
if kind != "task" && kind != "project" && kind != "note" {
|
||||
writeError(w, http.StatusBadRequest, CodeValidation, "kind must be task, project or note")
|
||||
return
|
||||
}
|
||||
found, err := s.db.NotesLinking(r.Context(), kind, chi.URLParam(r, "targetID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "notes linking", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A note in a project is the project's; a note in no project is its author's.
|
||||
// Both answers come from the project's own roles rather than from a rule notes
|
||||
// invented for themselves, which is the point of filing them there.
|
||||
func TestANoteIsNotReadableByJustAnybody(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Noget privat om #Firma",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
id, _ := body["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("no id came back")
|
||||
}
|
||||
|
||||
// The tags were read out of the text on the way in, without being asked for.
|
||||
links, _ := body["links"].([]any)
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("the note recorded %v", body["links"])
|
||||
}
|
||||
|
||||
// Somebody else gets the same answer as for a note that does not exist, so an
|
||||
// id cannot be probed for existence.
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Searching must not become a way to read what you cannot open. The filter is
|
||||
// applied after the index, and this is what says so.
|
||||
func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Hemmeligt kodeord til hjemmesiden",
|
||||
}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
_, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
||||
if got := len(mine["notes"].([]any)); got != 1 {
|
||||
t.Fatalf("the author found %d of their own notes", got)
|
||||
}
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
_, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
||||
if got := len(theirs["notes"].([]any)); got != 0 {
|
||||
t.Errorf("a stranger's search returned %d notes", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The backwards question, which is what makes notes worth linking at all.
|
||||
func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ring til Anders",
|
||||
})
|
||||
taskID, _ := task["id"].(string)
|
||||
if taskID == "" {
|
||||
t.Fatalf("no task id: %v", task)
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Aftalt at han ringer først, se /opgave/" + taskID,
|
||||
}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatal("the note was not written")
|
||||
}
|
||||
|
||||
_, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil)
|
||||
notes, _ := found["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("%d notes point at the task, want 1", len(notes))
|
||||
}
|
||||
}
|
||||
|
||||
// Editing the text moves the links with it. A link that outlives its mention
|
||||
// claims a connection that is not there.
|
||||
func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"})
|
||||
id, _ := note["id"].(string)
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if len(before["notes"].([]any)) != 1 {
|
||||
t.Fatal("the first save recorded nothing")
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{
|
||||
"body": "Om #Regnskab i stedet",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("the note was not changed")
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if got := len(after["notes"].([]any)); got != 0 {
|
||||
t.Errorf("%d notes still point at Firma", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -98,6 +98,12 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
|
||||
}
|
||||
}
|
||||
|
||||
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||
// which has the schedule but not the keys.
|
||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
s.pushToUser(userID, title, body, projectID)
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||
defer cancel()
|
||||
@@ -148,7 +154,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 +178,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 +190,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)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Asking the panel to restart this instance.
|
||||
//
|
||||
// A container cannot replace its own image from the inside, so "update" is really
|
||||
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
|
||||
// the way, which is what makes a restart an update at all.
|
||||
//
|
||||
// The alternative was a second browser tab: read the version here, go to the
|
||||
// panel, find the server, press Restart. This removes the tab, not the panel's
|
||||
// authority — the panel still does the work and still records who asked.
|
||||
//
|
||||
// Administrators only, and sessions only. A leaked API token that could restart
|
||||
// the server is a denial of service in one request, and the whole point of the
|
||||
// session rule elsewhere in this file is that a stolen token must not be able to
|
||||
// do things only a person at a keyboard should.
|
||||
|
||||
type panelStatusJSON struct {
|
||||
// Configured is all three settings being present. The token itself is never
|
||||
// sent: the page needs to know whether the button can work, not what the
|
||||
// credential is.
|
||||
Configured bool `json:"configured"`
|
||||
PanelURL string `json:"panel_url,omitempty"`
|
||||
// Why it cannot work, when it cannot. An operator reading "not configured"
|
||||
// should not have to go and find out which of three things is missing.
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) panelStatus() panelStatusJSON {
|
||||
var missing []string
|
||||
if s.cfg.PanelURL == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_URL")
|
||||
}
|
||||
if s.cfg.PanelToken == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_TOKEN")
|
||||
}
|
||||
if s.cfg.PanelServerID == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
|
||||
}
|
||||
return panelStatusJSON{
|
||||
Configured: len(missing) == 0,
|
||||
PanelURL: s.cfg.PanelURL,
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.panelStatus())
|
||||
}
|
||||
|
||||
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.panelStatus()
|
||||
if !status.Configured {
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
|
||||
|
||||
// A short timeout of its own. The panel stops this container as part of
|
||||
// answering, so the reply may never arrive — that is not a failure, and a
|
||||
// request that hung waiting for it would look like one.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// safe-restart rather than restart, and the difference is not politeness.
|
||||
//
|
||||
// The plain endpoint does its work while the request is open: it stops the
|
||||
// container — which kills this process — and the panel, seeing the caller
|
||||
// vanish, never gets to the starting half. That took production down and left
|
||||
// it down for four minutes on 18 August, and it cannot be fixed from this side:
|
||||
// any button that synchronously asks for its own death has the same shape.
|
||||
//
|
||||
// safe-restart is scheduled. The panel answers "Restart scheduled" and does the
|
||||
// work on its own afterwards, so nothing depends on this process still being
|
||||
// alive to hear the reply.
|
||||
url := fmt.Sprintf("%s/api/servers/%s/safe-restart", s.cfg.PanelURL, s.cfg.PanelServerID)
|
||||
body := strings.NewReader(`{"backup_first":false,"target_id":""}`)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
s.internal(w, r, "build panel request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
// Including "the panel killed us mid-reply", which is the successful case
|
||||
// wearing a failure's clothes. The interface polls /healthz rather than
|
||||
// believing either answer, so this only has to be honest about what it saw.
|
||||
s.log.Warn("panel restart call did not complete", "err", err)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"restarting": true,
|
||||
"note": "the panel did not answer; it may already be restarting this container",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal,
|
||||
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// A panel that never answers must not become this server's problem. The container
|
||||
// is being asked to stop, so the reply may legitimately never arrive — but the
|
||||
// request has to end either way, or whatever sits in front answers for it.
|
||||
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Drained first. A real server reads what it was sent, and one that does not
|
||||
// never learns the caller has hung up — httptest.Server.Close then waits for
|
||||
// a handler that will never return, and the whole binary times out instead
|
||||
// of the one request.
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "test-token"
|
||||
cfg.PanelServerID = "server-1"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Its own client: the shared one gives up at ten seconds, which is the very
|
||||
// budget under test, so it could not tell a pass from a failure.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s)", err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
took := time.Since(started)
|
||||
|
||||
if took > 20*time.Second {
|
||||
t.Errorf("the restart took %s; that is long enough for a proxy to answer first",
|
||||
took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Errorf("status %d, want 202 — a panel that went quiet is the expected case, "+
|
||||
"not a failure", resp.StatusCode)
|
||||
}
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
// And a panel that says no must say so in this server's own words, not in a proxy's.
|
||||
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "wrong-token"
|
||||
cfg.PanelServerID = "server-1"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
|
||||
}
|
||||
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
|
||||
t.Errorf("the reply does not say what the panel answered: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Forty copies of one mail arrived in a single second because two runs read the
|
||||
// same marker before either wrote a new one — somebody pressing "Fetch now" while
|
||||
// the ten-minute sweep was already inside the same mailbox.
|
||||
//
|
||||
// The lock is what stops it, and a lock is only worth having if something tries
|
||||
// the door: this runs the two concurrently on purpose.
|
||||
func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
// A bare server: the lock needs nothing but its own zero value, and building a
|
||||
// database and a session around it would only make the test slower to read.
|
||||
srv := &Server{}
|
||||
|
||||
var inside, most int
|
||||
var mu sync.Mutex
|
||||
release := make(chan struct{})
|
||||
|
||||
// Stands in for a run: takes the lock, notes how many are inside, and holds it
|
||||
// until told to let go.
|
||||
run := func(done chan<- struct{}) {
|
||||
unlock := srv.lockMailbox("box-1")
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > most {
|
||||
most = inside
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
<-release
|
||||
|
||||
mu.Lock()
|
||||
inside--
|
||||
mu.Unlock()
|
||||
unlock()
|
||||
done <- struct{}{}
|
||||
}
|
||||
|
||||
first, second := make(chan struct{}), make(chan struct{})
|
||||
go run(first)
|
||||
go run(second)
|
||||
|
||||
// Long enough that both would be inside if nothing kept them apart.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
<-first
|
||||
<-second
|
||||
|
||||
if most != 1 {
|
||||
t.Errorf("%d runs were inside the same mailbox at once; that is how one mail "+
|
||||
"becomes forty tasks", most)
|
||||
}
|
||||
|
||||
// And two different mailboxes must not wait for each other: one slow host is
|
||||
// not everybody's problem.
|
||||
a := srv.lockMailbox("box-a")
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
srv.lockMailbox("box-b")()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("a second mailbox waited for the first")
|
||||
}
|
||||
a()
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Passkeys: a key on a device instead of a secret in a head.
|
||||
//
|
||||
// Four endpoints, in two pairs. Registration is done by somebody already signed in
|
||||
// — you cannot be handed a way into an account you have not proved you own.
|
||||
// Logging in is done by somebody who is not, and is the one place here that is
|
||||
// reachable without a session.
|
||||
//
|
||||
// Each ceremony is two requests because the browser has to talk to the
|
||||
// authenticator in between: the server issues a challenge, the device signs it,
|
||||
// the server checks the signature against what it remembers issuing. The challenge
|
||||
// lives in the database rather than in a cookie, because the whole point of it is
|
||||
// that the server chose it and remembers choosing it.
|
||||
|
||||
const challengeTTL = 5 * time.Minute
|
||||
|
||||
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
|
||||
// built. 404 would read as "this version does not have passkeys"; the truth is
|
||||
// that this deployment cannot offer them, which is a different thing to fix.
|
||||
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.passkeys != nil {
|
||||
return true
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
|
||||
return false
|
||||
}
|
||||
|
||||
type passkeyJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// AAGUID identifies the authenticator model, which is what lets the list say
|
||||
// something recognisable beside a key somebody named three months ago.
|
||||
AAGUID string `json:"aaguid,omitempty"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
UserVerified bool `json:"user_verified"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list passkeys", err)
|
||||
return
|
||||
}
|
||||
out := make([]passkeyJSON, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
j := passkeyJSON{
|
||||
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
|
||||
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
|
||||
CreatedAt: k.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if !k.LastUsedAt.IsZero() {
|
||||
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
// Whether this deployment can offer them at all, alongside the ones that exist.
|
||||
// The interface asks one question rather than discovering the answer from a 503
|
||||
// it triggered by offering a button — an address an authenticator refuses is a
|
||||
// deployment fact, not an error somebody caused.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"passkeys": out, "available": s.passkeys != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// --- registering a key ------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Resident keys, and the authenticator asked to verify its owner. Both are
|
||||
// what make this a *passkey* rather than a second factor: only a discoverable
|
||||
// credential can start a login with no email typed, and only a verified one
|
||||
// stands in for the password rather than beside it.
|
||||
creation, session, err := s.passkeys.BeginRegistration(account,
|
||||
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
}),
|
||||
// Excluding what they already have, so a second registration on the same
|
||||
// device replaces nothing and reports itself instead of silently making a
|
||||
// duplicate.
|
||||
webauthn.WithExclusions(account.excludeList()),
|
||||
)
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey registration", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, user.ID, "register", session, creation)
|
||||
}
|
||||
|
||||
type finishRegistrationRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishRegistrationRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
key := &store.Passkey{
|
||||
UserID: user.ID,
|
||||
CredentialID: auth.EncodeCredentialID(credential.ID),
|
||||
PublicKey: credential.PublicKey,
|
||||
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
|
||||
SignCount: credential.Authenticator.SignCount,
|
||||
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
|
||||
UserVerified: credential.Flags.UserVerified,
|
||||
Name: name,
|
||||
}
|
||||
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
|
||||
s.internal(w, r, "store passkey", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, passkeyJSON{
|
||||
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
|
||||
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
|
||||
CreatedAt: key.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// --- signing in with a key ---------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Discoverable: nobody has said who they are yet, and that is the point. The
|
||||
// device knows which account its key belongs to, so there is no email to type
|
||||
// and no list of accounts for anybody to probe.
|
||||
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey login", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, "", "login", session, assertion)
|
||||
}
|
||||
|
||||
type finishLoginRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishLoginRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
// Which account, resolved from the credential the device signed with. The
|
||||
// handler is given the raw id and returns the owner; the library checks the
|
||||
// signature against that owner's stored public key.
|
||||
var matched *store.Passkey
|
||||
credential, err := s.passkeys.ValidateDiscoverableLogin(
|
||||
func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched = key
|
||||
owner, err := s.db.UserByID(r.Context(), key.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
|
||||
}, *session, parsed)
|
||||
if err != nil || matched == nil {
|
||||
// One message, as with a wrong password: telling "no such key" apart from
|
||||
// "bad signature" is a way to learn which credentials exist here.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
// A counter that goes backwards means the credential has been cloned. Not
|
||||
// every authenticator keeps one, so zero on both sides is silence rather than
|
||||
// suspicion — but a real counter that has gone down is the one signal this
|
||||
// design gives, and it is refused rather than logged.
|
||||
if credential.Authenticator.CloneWarning {
|
||||
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
owner, err := s.db.UserByID(r.Context(), matched.UserID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load account", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
|
||||
s.log.Warn("record passkey use", "err", err)
|
||||
}
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
|
||||
// A verified key is both factors at once: the device proved possession and the
|
||||
// person proved they were there, which is exactly what a password plus a code
|
||||
// is for. One that only proved possession still stops at the code.
|
||||
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
|
||||
ttl := s.cfg.SessionTTL
|
||||
if pending {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if pending {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(owner)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
}
|
||||
|
||||
// --- managing them ------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||
return
|
||||
}
|
||||
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID, name); err != nil {
|
||||
s.storeError(w, r, "rename passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "delete passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- shared plumbing -----------------------------------------------------------------
|
||||
|
||||
// passkeyAccount is the library's view of an account, plus the rows behind it.
|
||||
type passkeyAccount struct {
|
||||
*auth.PasskeyUser
|
||||
stored []store.Passkey
|
||||
}
|
||||
|
||||
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
|
||||
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
|
||||
for _, c := range a.PasskeyUser.Credentials {
|
||||
out = append(out, c.Descriptor())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials := make([]webauthn.Credential, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
raw, err := auth.DecodeCredentialID(k.CredentialID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
credentials = append(credentials, webauthn.Credential{
|
||||
ID: raw,
|
||||
PublicKey: k.PublicKey,
|
||||
Authenticator: webauthn.Authenticator{
|
||||
SignCount: k.SignCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
return &passkeyAccount{
|
||||
PasskeyUser: &auth.PasskeyUser{
|
||||
ID: userID, Email: email, Name: name, Credentials: credentials,
|
||||
},
|
||||
stored: keys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// issueChallenge stores the server's side of a ceremony and hands the browser the
|
||||
// options plus the id it will answer with.
|
||||
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
|
||||
encoded, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
s.internal(w, r, "encode webauthn session", err)
|
||||
return
|
||||
}
|
||||
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store challenge", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
|
||||
}
|
||||
|
||||
// takeChallenge reads a challenge once and checks it belongs where it is being
|
||||
// used. A registration challenge answered by a login — or one account's answered
|
||||
// by another — is refused rather than merely failing later.
|
||||
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
|
||||
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"that attempt has expired or was already used; start again")
|
||||
return nil, false
|
||||
}
|
||||
if userID != "" && owner != userID {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
|
||||
return nil, false
|
||||
}
|
||||
var session webauthn.SessionData
|
||||
if err := json.Unmarshal(raw, &session); err != nil {
|
||||
s.internal(w, r, "decode webauthn session", err)
|
||||
return nil, false
|
||||
}
|
||||
return &session, true
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// What can be tested without an authenticator, which is most of the surface that
|
||||
// actually goes wrong.
|
||||
//
|
||||
// The signature itself cannot be produced here — that needs a real device or a
|
||||
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
|
||||
// What is testable is everything around it: who may reach these endpoints, that a
|
||||
// challenge is answerable exactly once, that one account's challenge cannot be
|
||||
// answered by another, and that an expired one is refused. Those are the parts a
|
||||
// mistake would make silently permissive.
|
||||
|
||||
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
|
||||
// A leaked API token must not be able to add its own way in: that would turn a
|
||||
// theft into a tenancy.
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/auth/passkeys"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/begin"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/finish"},
|
||||
} {
|
||||
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 API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beginning a registration hands back options and a challenge id, and the options
|
||||
// carry the things that make this a passkey rather than a second factor.
|
||||
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["challenge_id"] == "" || body["challenge_id"] == nil {
|
||||
t.Fatal("no challenge id came back")
|
||||
}
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
if publicKey == nil {
|
||||
t.Fatalf("no publicKey in the options: %v", options)
|
||||
}
|
||||
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
|
||||
if selection["residentKey"] != "preferred" {
|
||||
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
|
||||
"with no email typed", selection["residentKey"])
|
||||
}
|
||||
if selection["userVerification"] != "preferred" {
|
||||
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
|
||||
"stand in for the password", selection["userVerification"])
|
||||
}
|
||||
// The user handle must be the account id rather than the email: it is written
|
||||
// into the credential and cannot be changed, so an email there would orphan
|
||||
// every key the moment somebody changed their address.
|
||||
user, _ := publicKey["user"].(map[string]any)
|
||||
if user["name"] != "[email protected]" {
|
||||
t.Errorf("user.name = %v", user["name"])
|
||||
}
|
||||
if id, _ := user["id"].(string); id == "" {
|
||||
t.Error("no user handle in the options")
|
||||
}
|
||||
}
|
||||
|
||||
// A challenge is answerable exactly once, and only by whom it was issued to.
|
||||
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
challenge := begun["challenge_id"].(string)
|
||||
|
||||
// Somebody else's challenge, answered by this account. Refused before the
|
||||
// signature is even looked at.
|
||||
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And it is spent: the rightful owner cannot use it either now, because taking
|
||||
// it is what deleted it. That is deliberate — a challenge that survives a
|
||||
// failed attempt is a challenge somebody can keep trying.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Logging in with a key needs no session and says nothing about who exists here.
|
||||
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// No session at all — a fresh client, which is the whole point.
|
||||
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
|
||||
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
challenge := body["challenge_id"].(string)
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
// A discoverable login must not list credentials: that list would say which
|
||||
// keys exist on this instance to anybody who asked.
|
||||
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
|
||||
if list, _ := allowed.([]any); len(list) > 0 {
|
||||
t.Errorf("the login options list %d credentials; a discoverable login must not",
|
||||
len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// A garbage answer is refused with the same message a wrong key would get.
|
||||
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A key belongs to its owner, and nobody else can rename or remove it.
|
||||
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Written straight to the store: registering one properly needs a device.
|
||||
key := &store.Passkey{
|
||||
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
|
||||
}
|
||||
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
|
||||
t.Fatalf("the owner sees %d keys, want 1", len(keys))
|
||||
}
|
||||
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
|
||||
t.Errorf("somebody else sees %d of this account's keys", len(keys))
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what, method string
|
||||
body any
|
||||
}{
|
||||
{"rename", "PATCH", map[string]any{"name": "min nu"}},
|
||||
{"delete", "DELETE", nil},
|
||||
} {
|
||||
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
|
||||
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner renaming: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner deleting: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -17,20 +19,25 @@ import (
|
||||
// that is not yours is not found.
|
||||
|
||||
type projectGroupJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
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, Collapsed: g.Collapsed, SortOrder: g.SortOrder}
|
||||
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, "list project groups", err)
|
||||
s.internal(w, r, "list project groups", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectGroupJSON, 0, len(groups))
|
||||
@@ -41,8 +48,10 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
type projectGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
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) {
|
||||
@@ -59,12 +68,23 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
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, "create project group", err)
|
||||
s.internal(w, r, "create project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||||
@@ -87,29 +107,163 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
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, Collapsed: req.Collapsed,
|
||||
Name: req.Name, Color: req.Color, Description: req.Description,
|
||||
Collapsed: req.Collapsed,
|
||||
}); err != nil {
|
||||
s.storeError(w, "update project group", err)
|
||||
s.storeError(w, r, "update project group", err)
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project group", err)
|
||||
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, "delete project group", err)
|
||||
s.storeError(w, r, "delete project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||||
@@ -128,7 +282,7 @@ func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder project groups", err)
|
||||
s.internal(w, r, "reorder project groups", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -219,3 +219,133 @@ func groupOf(t *testing.T, ts *testServer, projectID string) string {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type projectJSON struct {
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
Role string `json:"role"`
|
||||
MemberCount int `json:"member_count"`
|
||||
OpenCount int `json:"open_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
|
||||
@@ -34,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
|
||||
return 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,
|
||||
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount,
|
||||
Shared: p.MemberCount > 1, GroupID: p.GroupID,
|
||||
}
|
||||
}
|
||||
@@ -45,7 +46,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))
|
||||
@@ -59,7 +60,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))
|
||||
@@ -105,7 +106,7 @@ func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder projects", err)
|
||||
s.internal(w, r, "reorder projects", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -122,7 +123,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
|
||||
}
|
||||
@@ -138,9 +139,21 @@ 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))
|
||||
@@ -158,7 +171,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
|
||||
}
|
||||
@@ -168,7 +187,7 @@ 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
|
||||
@@ -178,7 +197,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
if req.GroupID != nil {
|
||||
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, "set project group", err)
|
||||
s.storeError(w, r, "set project group", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -186,7 +205,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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))
|
||||
@@ -200,7 +219,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -224,7 +243,7 @@ type trashedProjectJSON struct {
|
||||
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, "list trashed projects", err)
|
||||
s.internal(w, r, "list trashed projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -246,7 +265,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
@@ -254,13 +273,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 == "":
|
||||
@@ -275,6 +299,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
|
||||
}
|
||||
|
||||
@@ -290,7 +319,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))
|
||||
@@ -320,7 +349,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})
|
||||
@@ -334,7 +363,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 {
|
||||
@@ -365,7 +394,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)
|
||||
@@ -378,7 +407,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)
|
||||
@@ -398,7 +427,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))
|
||||
@@ -447,7 +476,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
|
||||
}
|
||||
|
||||
@@ -459,7 +488,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)})
|
||||
@@ -469,7 +498,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
|
||||
@@ -486,13 +515,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 {
|
||||
@@ -501,7 +577,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})
|
||||
@@ -513,13 +589,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,3 +194,230 @@ func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/mail"
|
||||
"github.com/kristianwind/verdande/internal/mcp"
|
||||
@@ -39,6 +41,24 @@ type Server struct {
|
||||
mcp *mcp.Server
|
||||
updates *update.Checker
|
||||
|
||||
// syncing serialises reads of one mailbox against itself. Two runs at once —
|
||||
// somebody pressing "Fetch now" while the ten-minute sweep is in the middle of
|
||||
// the same mailbox — both start from the marker as it was, both fetch the same
|
||||
// messages, and both make tasks out of them. That is how forty copies of one
|
||||
// mail arrived in a single second.
|
||||
//
|
||||
// A map of mutexes rather than one lock: two people's mailboxes have nothing to
|
||||
// say to each other, and a single lock would make one slow host everybody's
|
||||
// problem. Never cleaned out, because the key is a mailbox id and there are as
|
||||
// many of those as there are mailboxes.
|
||||
syncing sync.Map
|
||||
|
||||
// Nil when the base URL is not something an authenticator will accept — a bare
|
||||
// hostname with no scheme, say. The endpoints answer 503 rather than the
|
||||
// process refusing to start: passkeys are one way in among three, and an
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -63,6 +83,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
}
|
||||
s.mcp = s.buildMCP()
|
||||
s.updates = update.New(Version, cfg.UpdateCheck)
|
||||
|
||||
@@ -132,6 +157,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Method(http.MethodPost, "/password/reset",
|
||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
||||
|
||||
// Signing in with a passkey. Rate-limited like the password path: a
|
||||
// signature cannot be guessed, but the endpoint can still be used to
|
||||
// hammer the database.
|
||||
r.Method(http.MethodPost, "/passkey/login/begin",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
|
||||
r.Method(http.MethodPost, "/passkey/login/finish",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
|
||||
|
||||
// The second step of a login: a session that has passed the password
|
||||
// but not yet the code, and can reach nothing else.
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -144,6 +177,8 @@ 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.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
@@ -159,6 +194,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
|
||||
// Registering a key, and the list of them. Sessions only, for
|
||||
// the same reason: a leaked token must not be able to add its
|
||||
// own way in — that would turn a theft into a tenancy.
|
||||
r.Get("/passkeys", s.handleListPasskeys)
|
||||
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
|
||||
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
|
||||
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
|
||||
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -230,12 +274,45 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r.Get("/version", s.handleVersion)
|
||||
|
||||
// Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each
|
||||
// belongs to the person who connected it, so there is no admin gate
|
||||
// here and no shared registration: the credential is theirs.
|
||||
// Notes. Listing and searching are the same endpoint because they answer
|
||||
// the same question with different words; the backwards one — what has
|
||||
// been written about this task — hangs off the thing being asked about.
|
||||
r.Route("/notes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotes)
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListMailboxes)
|
||||
r.Post("/", s.handleAddMailbox)
|
||||
r.Delete("/{mailboxID}", s.handleDeleteMailbox)
|
||||
r.Post("/{mailboxID}/sync", s.handleSyncMailbox)
|
||||
})
|
||||
|
||||
r.Route("/gmail", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetGmail)
|
||||
r.Put("/", s.handleSetGmail)
|
||||
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) {
|
||||
@@ -264,6 +341,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
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) {
|
||||
@@ -292,11 +370,42 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 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)
|
||||
// Restarting this instance from its own interface. The panel does
|
||||
// the work — a container cannot replace its own image — and pulls
|
||||
// `:latest` on the way, which is what makes a restart an update.
|
||||
r.Get("/panel", s.handlePanelStatus)
|
||||
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||
// 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.
|
||||
@@ -305,8 +414,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
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
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list sessions", err)
|
||||
s.internal(w, r, "list sessions", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := chi.URLParam(r, "sessionID")
|
||||
|
||||
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
||||
s.storeError(w, "delete session", err)
|
||||
s.storeError(w, r, "delete session", err)
|
||||
return
|
||||
}
|
||||
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
||||
|
||||
@@ -33,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 {
|
||||
@@ -45,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,
|
||||
}
|
||||
@@ -99,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)})
|
||||
@@ -116,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))
|
||||
@@ -162,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
|
||||
}
|
||||
}
|
||||
@@ -226,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
|
||||
@@ -302,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)
|
||||
@@ -327,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
|
||||
}
|
||||
|
||||
@@ -369,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)
|
||||
@@ -394,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})
|
||||
@@ -427,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)
|
||||
@@ -449,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))
|
||||
@@ -468,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) {
|
||||
@@ -502,7 +513,7 @@ func (s *Server) handleQuickAdd(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
|
||||
}
|
||||
}
|
||||
@@ -511,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)
|
||||
@@ -523,20 +556,21 @@ 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 != "" {
|
||||
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"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@ 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"`
|
||||
@@ -117,7 +142,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "delegated", err)
|
||||
s.internal(w, r, "delegated", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
people, err := s.db.PeopleByIDs(r.Context(), ids)
|
||||
if err != nil {
|
||||
s.internal(w, "delegated people", err)
|
||||
s.internal(w, r, "delegated people", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,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
|
||||
}
|
||||
|
||||
@@ -192,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{}
|
||||
@@ -214,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
|
||||
}
|
||||
|
||||
|
||||
@@ -153,3 +153,50 @@ func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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.
|
||||
// The same words, inside the code rather than the markup.
|
||||
//
|
||||
// `confirm()`, `alert()` and `app.toast()` take a string argument, and a string
|
||||
// argument lives in the script block where the scanner above never looks. Six of
|
||||
// them survived the whole i18n sweep that way — every destructive confirmation in
|
||||
// the app, which is the worst possible set to leave in one language, because it is
|
||||
// the text somebody reads in the half-second before they agree to lose something.
|
||||
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
||||
// Every string literal in the argument list of one of the three, not just one
|
||||
// sitting flush against the paren. A toast written as a ternary over two lines
|
||||
// hid Danish from the earlier version of this guard for a whole release.
|
||||
// Deliberately still narrow: a general scan of every literal in a script block
|
||||
// would drown in class names, keys and query fragments.
|
||||
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
|
||||
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|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
|
||||
|
||||
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() {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(path, ".svelte") && !strings.HasSuffix(path, ".js") {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(path, "locales") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
src := string(body)
|
||||
for _, loc := range call.FindAllStringIndex(src, -1) {
|
||||
for _, lit := range literalsInCall(src[loc[1]:]) {
|
||||
if danish.MatchString(lit) {
|
||||
found = append(found, rel+": "+lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
t.Errorf("%d dialog or toast string(s) are written in Danish inside the code. "+
|
||||
"They are what somebody reads before agreeing to lose something, and an "+
|
||||
"English interface shows them in Danish:\n %s",
|
||||
len(found), strings.Join(found, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// literalsInCall returns the string literals in one argument list, given the text
|
||||
// just past its opening paren. It stops at the matching close paren so the next
|
||||
// statement's strings are somebody else's problem.
|
||||
func literalsInCall(src string) []string {
|
||||
var out []string
|
||||
depth := 1
|
||||
for i := 0; i < len(src); i++ {
|
||||
switch c := src[i]; c {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth--; depth == 0 {
|
||||
return out
|
||||
}
|
||||
case '\'', '"', '`':
|
||||
end := strings.IndexByte(src[i+1:], c)
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
|
||||
out = append(out, lit)
|
||||
}
|
||||
i += end + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Package imap reads a mailbox over IMAP and hands back the messages that should
|
||||
// become tasks.
|
||||
//
|
||||
// This exists next to internal/gmail rather than inside it because the two agree
|
||||
// on almost nothing. Gmail is an HTTP API with OAuth, labels and stars; IMAP is a
|
||||
// connection with a password, folders and flags. What they do share is the shape
|
||||
// of the answer — a handful of messages, each with a sender, a subject and enough
|
||||
// of a body to read — and that shape is Message here, deliberately identical to
|
||||
// what the Gmail client returns, so the caller can be told where to look and not
|
||||
// how to look.
|
||||
//
|
||||
// iCloud, Fastmail and any ordinary host speak this. Office 365 no longer accepts
|
||||
// a password here at all — it wants OAuth of its own — so it is not a host you can
|
||||
// reach with this package, and pretending otherwise would only fail later and
|
||||
// less clearly.
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-message"
|
||||
)
|
||||
|
||||
// Message is one mail, reduced to what a task needs.
|
||||
type Message struct {
|
||||
UID uint32
|
||||
From string
|
||||
Subject string
|
||||
Snippet string
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// Account is one mailbox to read.
|
||||
type Account struct {
|
||||
Host string // "imap.mail.me.com:993"
|
||||
Username string
|
||||
Password string // an app-specific password on every host worth naming
|
||||
Folder string // "INBOX" when empty
|
||||
|
||||
// RootCAs adds a certificate authority to trust, on top of the system's. It
|
||||
// exists so a test can put a real IMAP server behind a self-signed certificate
|
||||
// and still be refused by everything else — the field can only widen trust,
|
||||
// never switch verification off, which is the difference between a seam and a
|
||||
// hole. Nil in production, where the system's roots are the answer.
|
||||
RootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
// Dial opens a TLS connection and logs in. The caller closes it.
|
||||
//
|
||||
// TLS is not optional and there is no flag to make it so: this carries a password
|
||||
// that unlocks a person's whole mailbox, and a switch for turning that protection
|
||||
// off is a switch that will one day be found already flipped.
|
||||
func Dial(a Account) (*Client, error) {
|
||||
host, _, err := net.SplitHostPort(a.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host must include a port, like imap.mail.me.com:993: %w", err)
|
||||
}
|
||||
|
||||
c, err := imapclient.DialTLS(a.Host, &imapclient.Options{
|
||||
TLSConfig: &tls.Config{
|
||||
ServerName: host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: a.RootCAs,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to %s: %w", a.Host, err)
|
||||
}
|
||||
|
||||
if err := c.Login(a.Username, a.Password).Wait(); err != nil {
|
||||
c.Close()
|
||||
// Deliberately not repeating the server's own words here. A bad password is
|
||||
// the overwhelmingly likely cause, and hosts answer it with everything from
|
||||
// "AUTHENTICATIONFAILED" to a sentence about web login being required.
|
||||
return nil, fmt.Errorf("%s refused the login for %s: %w", a.Host, a.Username, err)
|
||||
}
|
||||
|
||||
folder := a.Folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
return &Client{c: c, folder: folder}, nil
|
||||
}
|
||||
|
||||
// Client is a logged-in connection to one mailbox.
|
||||
type Client struct {
|
||||
c *imapclient.Client
|
||||
folder string
|
||||
}
|
||||
|
||||
// Close ends the session politely, then hangs up regardless.
|
||||
func (c *Client) Close() error {
|
||||
if err := c.c.Logout().Wait(); err != nil {
|
||||
return c.c.Close()
|
||||
}
|
||||
return c.c.Close()
|
||||
}
|
||||
|
||||
// Since returns the flagged messages in the folder newer than uid.
|
||||
//
|
||||
// Flagged, not unread: unread is a state the person is still using, and a sync
|
||||
// that consumed it would be taking something away. Flagging a mail is a deliberate
|
||||
// act with no other meaning, which is exactly what is wanted — the same reason the
|
||||
// Gmail side reads stars.
|
||||
// The second return is the highest uid the search considered. A caller that got
|
||||
// through every message can move its marker to that, and then it does not matter
|
||||
// whether the server put a uid in each fetch reply — which some do not, and which
|
||||
// is how the same mail became a task again on every run.
|
||||
func (c *Client) Since(uid uint32, limit int) ([]Message, uint32, error) {
|
||||
if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||
return nil, 0, fmt.Errorf("open %s: %w", c.folder, err)
|
||||
}
|
||||
|
||||
// From uid+1 to the end. A mailbox with nothing new answers with no uids
|
||||
// rather than an error, and that is not a failure worth reporting upwards.
|
||||
set := imap.UIDSetNum()
|
||||
set.AddRange(imap.UID(uid+1), 0)
|
||||
|
||||
criteria := &imap.SearchCriteria{
|
||||
UID: []imap.UIDSet{set},
|
||||
Flag: []imap.Flag{imap.FlagFlagged},
|
||||
}
|
||||
found, err := c.c.UIDSearch(criteria, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search %s: %w", c.folder, err)
|
||||
}
|
||||
|
||||
uids := found.AllUIDs()
|
||||
|
||||
// Anything at or below the marker is dropped here rather than trusted to the
|
||||
// range in the query. A server that reads `1272:*` as `0:1272` — and one does,
|
||||
// which is how the same mail became a task every ten minutes with the marker
|
||||
// standing still at 1271 — hands back the whole mailbox. The marker is ours;
|
||||
// enforcing it here costs one pass over a list of at most a few hundred
|
||||
// numbers and does not depend on anybody else's reading of the spec.
|
||||
kept := make([]imap.UID, 0, len(uids))
|
||||
for _, u := range uids {
|
||||
if uint32(u) > uid {
|
||||
kept = append(kept, u)
|
||||
}
|
||||
}
|
||||
uids = kept
|
||||
|
||||
if len(uids) == 0 {
|
||||
return nil, uid, nil
|
||||
}
|
||||
if limit > 0 && len(uids) > limit {
|
||||
// The newest ones. A mailbox flagged over years would otherwise make the
|
||||
// first sync the largest one, which is the run most likely to be watched.
|
||||
uids = uids[len(uids)-limit:]
|
||||
}
|
||||
|
||||
want := imap.UIDSetNum(uids...)
|
||||
fetch := &imap.FetchOptions{
|
||||
Envelope: true,
|
||||
UID: true,
|
||||
// The whole message, headers included, so the MIME structure can be walked.
|
||||
// TEXT alone gives the body as bytes — boundaries and part headers and all —
|
||||
// which is what put "--=_be71cb39… Content-Transfer-Encoding: 8bit" at the
|
||||
// front of every task made from a multipart mail.
|
||||
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
|
||||
}
|
||||
var highest uint32
|
||||
for _, u := range uids {
|
||||
if uint32(u) > highest {
|
||||
highest = uint32(u)
|
||||
}
|
||||
}
|
||||
|
||||
msgs, err := c.c.Fetch(want, fetch).Collect()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("fetch from %s: %w", c.folder, err)
|
||||
}
|
||||
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for i, m := range msgs {
|
||||
if m.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// The uid comes from the search when the fetch reply does not carry one.
|
||||
// A missing uid is not cosmetic: it is the marker the next run starts from,
|
||||
// and a marker stuck at zero makes every message arrive again on every run.
|
||||
// That is what happened against a real server while the tests stayed green,
|
||||
// because the server they run against always answers with it.
|
||||
msgUID := uint32(m.UID)
|
||||
if msgUID == 0 && i < len(uids) {
|
||||
msgUID = uint32(uids[i])
|
||||
}
|
||||
|
||||
out = append(out, Message{
|
||||
UID: msgUID,
|
||||
From: address(m.Envelope.From),
|
||||
Subject: strings.TrimSpace(m.Envelope.Subject),
|
||||
Snippet: snippet(m),
|
||||
Date: m.Envelope.Date,
|
||||
})
|
||||
}
|
||||
return out, highest, nil
|
||||
}
|
||||
|
||||
// address renders the first sender as "Name <addr>", or just the address.
|
||||
func address(list []imap.Address) string {
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
a := list[0]
|
||||
addr := a.Addr()
|
||||
if a.Name == "" {
|
||||
return addr
|
||||
}
|
||||
return a.Name + " <" + addr + ">"
|
||||
}
|
||||
|
||||
// snippet is the first readable text in the mail.
|
||||
//
|
||||
// It walks the MIME structure rather than taking the body as bytes. text/plain is
|
||||
// preferred and HTML is the fallback with its tags stripped — crudely, because
|
||||
// this is the line under a task's title and not a rendering engine. The mail
|
||||
// itself stays where it is.
|
||||
const snippetLimit = 400
|
||||
|
||||
func snippet(m *imapclient.FetchMessageBuffer) string {
|
||||
for _, body := range m.BodySection {
|
||||
if text := readable(body.Bytes); text != "" {
|
||||
return cut(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readable(raw []byte) string {
|
||||
entity, err := message.Read(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
// Not something this parser recognises. The raw bytes read badly but read,
|
||||
// and a mail is not worth losing over its own headers.
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
return strings.TrimSpace(fromEntity(entity, 0))
|
||||
}
|
||||
|
||||
// fromEntity descends into a multipart mail. The depth guard is for the mail that
|
||||
// nests parts inside parts inside parts — rare, and cheaper to bound than to trust.
|
||||
func fromEntity(entity *message.Entity, depth int) string {
|
||||
if depth > 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if parts := entity.MultipartReader(); parts != nil {
|
||||
var html string
|
||||
for {
|
||||
part, err := parts.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if text := fromEntity(part, depth+1); text != "" {
|
||||
kind, _, _ := part.Header.ContentType()
|
||||
if kind != "text/html" {
|
||||
return text
|
||||
}
|
||||
if html == "" {
|
||||
html = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(entity.Body)
|
||||
kind, _, _ := entity.Header.ContentType()
|
||||
if kind == "text/html" {
|
||||
return stripTags(string(body))
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// stripTags removes markup without pretending to understand it. Enough for one
|
||||
// line of preview, and honest about being no more than that.
|
||||
func stripTags(html string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range html {
|
||||
switch {
|
||||
case r == '<':
|
||||
depth++
|
||||
case r == '>':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case depth == 0:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func cut(text string) string {
|
||||
runes := []rune(text)
|
||||
if len(runes) > snippetLimit {
|
||||
return string(runes[:snippetLimit]) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
)
|
||||
|
||||
// A host without a port is the mistake everyone makes once, and the error a TLS
|
||||
// dial gives for it says nothing useful about which of the two fields is wrong.
|
||||
func TestAHostWithoutAPortSaysSo(t *testing.T) {
|
||||
_, err := Dial(Account{Host: "imap.mail.me.com", Username: "kw", Password: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("a host with no port was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "port") {
|
||||
t.Errorf("the error does not mention the port: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASenderIsRenderedForReading(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
in []imap.Address
|
||||
want string
|
||||
}{
|
||||
{"nobody", nil, ""},
|
||||
{"bare address", []imap.Address{{Mailbox: "kw", Host: "nolimit.dk"}}, "[email protected]"},
|
||||
{"with a name", []imap.Address{{Name: "Kristian Wind", Mailbox: "kw", Host: "nolimit.dk"}},
|
||||
"Kristian Wind <[email protected]>"},
|
||||
// The first sender wins. A mail from two people is rare enough that naming
|
||||
// one is better than a task titled with a list.
|
||||
{"two senders", []imap.Address{
|
||||
{Mailbox: "first", Host: "example.dk"},
|
||||
{Mailbox: "second", Host: "example.dk"},
|
||||
}, "[email protected]"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := address(c.in); got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The snippet is what shows under a task's title, so its only real duty is to end.
|
||||
func TestASnippetIsTrimmedAndBounded(t *testing.T) {
|
||||
long := strings.Repeat("a", snippetLimit*2)
|
||||
m := &imapclient.FetchMessageBuffer{
|
||||
BodySection: []imapclient.FetchBodySectionBuffer{
|
||||
{Bytes: []byte(" ")},
|
||||
{Bytes: []byte(" " + long + " ")},
|
||||
},
|
||||
}
|
||||
got := snippet(m)
|
||||
if strings.HasPrefix(got, " ") || strings.HasSuffix(got, " ") {
|
||||
t.Errorf("the snippet is not trimmed: %q", got[:20])
|
||||
}
|
||||
// An empty part must not win over the one that has the text.
|
||||
if !strings.HasPrefix(got, "aaa") {
|
||||
t.Errorf("an empty body part was preferred: %q", got)
|
||||
}
|
||||
if len([]rune(got)) > snippetLimit+1 {
|
||||
t.Errorf("the snippet runs to %d characters", len([]rune(got)))
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Error("a cut snippet does not say that it was cut")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyMailboxIsNotAnError(t *testing.T) {
|
||||
if got := snippet(&imapclient.FetchMessageBuffer{}); got != "" {
|
||||
t.Errorf("a mail with no body parts gave %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
|
||||
)
|
||||
|
||||
// The round trip, against a real IMAP server rather than a stub of one. The
|
||||
// earlier tests cover the pure parts — what a sender renders as, where a snippet
|
||||
// is cut — and would all have passed with the fetch itself broken.
|
||||
func TestFlaggedMailComesBackAsMessages(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura 4711", from: "Anders Bo <[email protected]>", body: "Vedhæftet.", flagged: true},
|
||||
{subject: "Nyhedsbrev", from: "[email protected]", body: "Læs mere", flagged: false},
|
||||
{subject: "Kontrakt", from: "[email protected]", body: "Til underskrift", flagged: true},
|
||||
})
|
||||
|
||||
client, err := Dial(Account{
|
||||
Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two of the three. The newsletter is unflagged, and flagging is the whole
|
||||
// signal: unread is a state the owner is still using.
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("got %d messages, want the 2 flagged ones", len(msgs))
|
||||
}
|
||||
if msgs[0].Subject != "Faktura 4711" {
|
||||
t.Errorf("first subject is %q", msgs[0].Subject)
|
||||
}
|
||||
if !strings.Contains(msgs[0].From, "[email protected]") {
|
||||
t.Errorf("first sender is %q", msgs[0].From)
|
||||
}
|
||||
if msgs[0].UID == 0 {
|
||||
t.Error("no uid came back, so a second run would fetch everything again")
|
||||
}
|
||||
|
||||
// And reading on from a marker returns only what is newer, which is the whole
|
||||
// of the deduplication.
|
||||
rest, _, err := client.Since(msgs[0].UID, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rest) != 1 || rest[0].Subject != "Kontrakt" {
|
||||
t.Fatalf("reading on from uid %d gave %d messages", msgs[0].UID, len(rest))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongPasswordIsRefusedClearly(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, nil)
|
||||
_, err := Dial(Account{Host: addr, Username: "kw", Password: "forkert", RootCAs: pool})
|
||||
if err == nil {
|
||||
t.Fatal("a wrong password was accepted")
|
||||
}
|
||||
// Hosts say everything from AUTHENTICATIONFAILED to a sentence about the web,
|
||||
// so the reply names the host and the user rather than repeating their words.
|
||||
if !strings.Contains(err.Error(), "kw") {
|
||||
t.Errorf("the error does not say whose login was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A certificate this process signed itself must not be enough on its own.
|
||||
func TestAnUntrustedCertificateIsRefused(t *testing.T) {
|
||||
addr, _ := serveMailbox(t, nil)
|
||||
if _, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig"}); err == nil {
|
||||
t.Fatal("a self-signed certificate was accepted without being trusted")
|
||||
}
|
||||
}
|
||||
|
||||
type mail struct {
|
||||
subject, from, body string
|
||||
flagged bool
|
||||
// raw replaces the whole message when set, which is the only way to get a
|
||||
// genuinely multipart one in.
|
||||
raw string
|
||||
}
|
||||
|
||||
// serveMailbox starts an in-process IMAP server over TLS and returns its address
|
||||
// and the pool that trusts it.
|
||||
func serveMailbox(t *testing.T, mails []mail) (string, *x509.CertPool) {
|
||||
t.Helper()
|
||||
|
||||
cert, pool := selfSigned(t)
|
||||
memServer := imapmemserver.New()
|
||||
user := imapmemserver.NewUser("kw", "hemmelig")
|
||||
if err := user.Create("INBOX", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memServer.AddUser(user)
|
||||
|
||||
for _, m := range mails {
|
||||
flags := []imap.Flag{}
|
||||
if m.flagged {
|
||||
flags = append(flags, imap.FlagFlagged)
|
||||
}
|
||||
raw := m.raw
|
||||
if raw == "" {
|
||||
raw = "From: " + m.from + "\r\nSubject: " + m.subject +
|
||||
"\r\nDate: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + m.body + "\r\n"
|
||||
}
|
||||
if _, err := user.Append("INBOX", strings.NewReader(raw), &imap.AppendOptions{
|
||||
Flags: flags,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := imapserver.New(&imapserver.Options{
|
||||
NewSession: func(_ *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return memServer.NewSession(), nil, nil
|
||||
},
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
InsecureAuth: false,
|
||||
})
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Serve(ln)
|
||||
|
||||
// "localhost", not the loopback address: the certificate names a host, and the
|
||||
// client checks that name — as it should.
|
||||
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
return net.JoinHostPort("localhost", port), pool
|
||||
}
|
||||
|
||||
func selfSigned(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(certPEM)
|
||||
return cert, pool
|
||||
}
|
||||
|
||||
// The bug this was written for: every press of "Fetch now" made the same mail
|
||||
// into a task again. The marker the next run starts from is the uid, and if it
|
||||
// comes back as zero the marker never moves.
|
||||
func TestEveryMessageCarriesAUID(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "En", from: "[email protected]", body: "en", flagged: true},
|
||||
{subject: "To", from: "[email protected]", body: "to", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.UID == 0 {
|
||||
t.Fatalf("%q came back without a uid; the marker would never move and "+
|
||||
"every run would make it into a task again", m.Subject)
|
||||
}
|
||||
}
|
||||
if msgs[0].UID == msgs[1].UID {
|
||||
t.Error("two messages share a uid")
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same report: the snippet under a task's title read
|
||||
// "--=_be71cb39… Content-Transfer-Encoding: 8bit Content-Type: text/plain".
|
||||
func TestAMultipartMailReadsAsItsText(t *testing.T) {
|
||||
const boundary = "=_be71cb3975bdfbc60a16327cb2f49c23"
|
||||
body := "This is a multi-part message.\r\n" +
|
||||
"--" + boundary + "\r\n" +
|
||||
"Content-Transfer-Encoding: 8bit\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n\r\n" +
|
||||
"Hej igen,\r\n\r\nVi har normalt 3 slags. :)\r\n" +
|
||||
"--" + boundary + "\r\n" +
|
||||
"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
|
||||
"<html><body><p>Hej igen</p></body></html>\r\n" +
|
||||
"--" + boundary + "--\r\n"
|
||||
|
||||
addr, pool := serveMailbox(t, []mail{{
|
||||
flagged: true,
|
||||
raw: "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n" +
|
||||
"From: [email protected]\r\nSubject: Re: Kaffebestilling\r\n" +
|
||||
"Date: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + body,
|
||||
}})
|
||||
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("got %d messages", len(msgs))
|
||||
}
|
||||
|
||||
got := msgs[0].Snippet
|
||||
for _, junk := range []string{boundary, "Content-Transfer-Encoding", "Content-Type", "<html>", "<p>"} {
|
||||
if strings.Contains(got, junk) {
|
||||
t.Errorf("the snippet still carries %q: %q", junk, got)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(got, "Hej igen") {
|
||||
t.Errorf("the snippet does not start with the text of the mail: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The marker is ours, not the server's.
|
||||
//
|
||||
// A mailbox read with a marker above everything in it must come back empty. The
|
||||
// query says so, but a server is free to read the range differently — one reads
|
||||
// `1272:*` as `0:1272` and hands back the lot, which is how the same mail became
|
||||
// a task every ten minutes while the marker stood still at 1271. The filter is
|
||||
// on this side now, so the invariant holds whatever comes back.
|
||||
func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "En", from: "[email protected]", body: "en", flagged: true},
|
||||
{subject: "To", from: "[email protected]", body: "to", flagged: true},
|
||||
{subject: "Tre", from: "[email protected]", body: "tre", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
all, highest, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("got %d messages on the first read, want 3", len(all))
|
||||
}
|
||||
|
||||
// Read again from the marker the first run would have written.
|
||||
rest, again, err := client.Since(highest, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Errorf("%d message(s) came back at or below the marker: %v", len(rest), rest[0].Subject)
|
||||
}
|
||||
// And the marker must not go backwards, or the next run reads them all again.
|
||||
if again < highest {
|
||||
t.Errorf("the marker moved back from %d to %d", highest, again)
|
||||
}
|
||||
|
||||
// Every message, individually: none of them may reappear once passed.
|
||||
for _, m := range all {
|
||||
later, _, err := client.Since(m.UID, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, l := range later {
|
||||
if l.UID <= m.UID {
|
||||
t.Errorf("reading from %d gave %d back", m.UID, l.UID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,14 @@ type Runner struct {
|
||||
// startable from main.
|
||||
SyncGmail func(ctx context.Context, user *store.User) (int, error)
|
||||
|
||||
// SyncMailbox reads one IMAP mailbox. Supplied by the HTTP layer for the same
|
||||
// reason as SyncGmail: it owns turning a message into a task.
|
||||
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
||||
|
||||
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -55,11 +63,12 @@ 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
|
||||
// starred and slow enough not to look like abuse.
|
||||
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
|
||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -121,6 +130,15 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
|
||||
"due": rem.RemindAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// And to the devices that asked for it. This was missing: reminders went out
|
||||
// in-app and by mail, so somebody with the tab closed and no mail configured
|
||||
// got a notification that never arrived — which is the case a reminder is
|
||||
// for. Notifications from other people's actions did push; only the timed
|
||||
// ones did not, and the interface said "on for this device" either way.
|
||||
if r.Push != nil {
|
||||
r.Push(rem.UserID, rem.TaskContent, reminderBody(rem.RemindAt), rem.ProjectID)
|
||||
}
|
||||
|
||||
if r.mail.Configured() && rem.UserEmail != "" {
|
||||
link := r.cfg.BaseURL + "/projekt/" + rem.ProjectID
|
||||
if err := r.mail.SendReminder(ctx, rem.UserEmail, rem.UserName, rem.TaskContent, link); err != nil {
|
||||
@@ -134,6 +152,12 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reminderBody is the line under the task's title. The time rather than the date:
|
||||
// a reminder arrives when it is due, so the day is today and saying so is noise.
|
||||
func reminderBody(at time.Time) string {
|
||||
return at.Format("15:04")
|
||||
}
|
||||
|
||||
// --- backups ---------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
@@ -161,22 +185,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 +228,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 +242,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,38 +258,65 @@ 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
|
||||
}
|
||||
|
||||
// --- gmail ---------------------------------------------------------------------------
|
||||
|
||||
// syncGmailAccounts polls every connected mailbox.
|
||||
// syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
|
||||
//
|
||||
// One at a time, and a failure on one account does not stop the others: a revoked
|
||||
// token or an expired refresh is a problem for that person alone.
|
||||
func (r *Runner) syncGmailAccounts(ctx context.Context) error {
|
||||
if r.SyncGmail == nil {
|
||||
// One sweep rather than two, now that both live in the same table. Nobody is
|
||||
// waiting, so no budget of its own — the request handler has one because a person
|
||||
// is watching a spinner. A host that has gone quiet costs this run its own dial
|
||||
// timeout and nothing else: the loop moves on, per mailbox and per person, so one
|
||||
// bad password does not stop everybody else's mail.
|
||||
func (r *Runner) syncMailboxes(ctx context.Context) error {
|
||||
if r.SyncMailbox == nil {
|
||||
return nil
|
||||
}
|
||||
users, err := r.db.UsersWithGmail(ctx)
|
||||
userIDs, err := r.db.UsersWithMailboxes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
created, err := r.SyncGmail(ctx, &user)
|
||||
if err != nil {
|
||||
r.log.Warn("gmail sync", "err", err, "user", user.ID)
|
||||
for _, id := range userIDs {
|
||||
user, err := r.db.UserByID(ctx, id)
|
||||
if err != nil || user == nil {
|
||||
continue
|
||||
}
|
||||
if created > 0 {
|
||||
r.log.Info("tasks created from gmail", "user", user.ID, "count", created)
|
||||
boxes, err := r.db.Mailboxes(ctx, id)
|
||||
if err != nil {
|
||||
r.log.Warn("list mailboxes", "err", err, "user", id)
|
||||
continue
|
||||
}
|
||||
for i := range boxes {
|
||||
var created int
|
||||
var err error
|
||||
// Gmail is fetched over HTTP with a token, IMAP over a connection with a
|
||||
// password. Which one is a property of the row, not of the loop.
|
||||
if boxes[i].Kind == "gmail" {
|
||||
if r.SyncGmail == nil {
|
||||
continue
|
||||
}
|
||||
created, err = r.SyncGmail(ctx, user)
|
||||
} else {
|
||||
created, err = r.SyncMailbox(ctx, user, &boxes[i])
|
||||
}
|
||||
if err != nil {
|
||||
r.log.Warn("mailbox sync", "err", err, "user", id,
|
||||
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
|
||||
continue
|
||||
}
|
||||
if created > 0 {
|
||||
r.log.Info("tasks created from a mailbox", "user", id,
|
||||
"kind", boxes[i].Kind, "count", created)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -265,6 +335,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 +360,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -276,3 +276,59 @@ func backupFiles(t *testing.T, cfg *config.Config) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A reminder that nobody receives is not a reminder.
|
||||
//
|
||||
// The job sent them in-app and by mail. Somebody with the tab closed and no mail
|
||||
// server configured — which is the setup the docker one-liner produces — got
|
||||
// nothing, while the settings page said notifications were on for the device.
|
||||
// The push half was simply never wired: notifications from other people's actions
|
||||
// went out, only the timed ones did not.
|
||||
func TestADueReminderReachesTheDevices(t *testing.T) {
|
||||
runner, db, _ := newRunner(t)
|
||||
user := seedUser(t, db)
|
||||
|
||||
type sent struct{ userID, title, projectID string }
|
||||
var got []sent
|
||||
runner.Push = func(userID, title, body, projectID string) {
|
||||
got = append(got, sent{userID, title, projectID})
|
||||
}
|
||||
|
||||
inbox, err := db.InboxID(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "ring til Anders", CreatedBy: user.ID}
|
||||
if err := db.CreateTask(t.Context(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A minute ago, so it is due rather than upcoming.
|
||||
at := time.Now().Add(-time.Minute)
|
||||
if _, err := db.CreateReminder(t.Context(), task.ID, user.ID, &at, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runner.sendDueReminders(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%d reminders reached the devices, want 1", len(got))
|
||||
}
|
||||
if got[0].userID != user.ID {
|
||||
t.Errorf("it went to %q", got[0].userID)
|
||||
}
|
||||
if got[0].title != "ring til Anders" {
|
||||
t.Errorf("the title is %q, not the task", got[0].title)
|
||||
}
|
||||
|
||||
// And once. A reminder that arrives on every sweep is worse than one that does
|
||||
// not arrive at all.
|
||||
got = nil
|
||||
if err := runner.sendDueReminders(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the same reminder went out %d more times", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Package secret seals the values that must not travel with a backup.
|
||||
//
|
||||
// The reason this exists is not the database on the server — it is the copies of
|
||||
// it. Backups can be downloaded through the web interface, and every one taken so
|
||||
// far carried a live Gmail refresh token in plain text. Anyone who ends up with a
|
||||
// copy ends up with the mailbox.
|
||||
//
|
||||
// So the key must not live in the database. That rules out the pattern used for
|
||||
// the VAPID pair, which the instance generates and stores in the same file it
|
||||
// protects: a key kept beside what it locks is not a lock. It comes from the
|
||||
// environment, or from a file next to the data that the backup does not include.
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// prefix marks a sealed value, so a mixed column can be read without guessing and
|
||||
// so the scheme can be replaced later without having to.
|
||||
const prefix = "v1:"
|
||||
|
||||
// KeyFile is where the key lands when the environment does not carry one. It sits
|
||||
// beside the database rather than inside it, and the backup routine must never
|
||||
// learn to include it.
|
||||
const KeyFile = "secret.key"
|
||||
|
||||
// Box seals and opens values.
|
||||
type Box struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// Open loads the key, generating and saving one on first use.
|
||||
//
|
||||
// A key given in the environment wins: that is how a host keeps the secret out of
|
||||
// its disks altogether. Otherwise a file, created 0600, and read back with its
|
||||
// permissions checked — a key that anybody on the box can read is a key that is
|
||||
// only ceremonially secret.
|
||||
func Open(env, dataDir string) (*Box, error) {
|
||||
if env != "" {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(env))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errors.New("VERDANDE_SECRET_KEY must be 32 bytes of base64 — " +
|
||||
"generate one with: openssl rand -base64 32")
|
||||
}
|
||||
return newBox(key)
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, KeyFile)
|
||||
raw, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
key, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
|
||||
if decErr != nil || len(key) != 32 {
|
||||
// Deliberately not regenerating. A new key here would not fix anything —
|
||||
// it would silently make every stored token unreadable, which looks
|
||||
// exactly like the mailbox having disconnected itself overnight.
|
||||
return nil, fmt.Errorf("%s is not a 32-byte base64 key; move it aside only "+
|
||||
"if you accept that every connected mailbox must be reconnected", path)
|
||||
}
|
||||
if info, statErr := os.Stat(path); statErr == nil && info.Mode().Perm()&0o077 != 0 {
|
||||
return nil, fmt.Errorf("%s is readable by others (%04o); chmod 600 it", path, info.Mode().Perm())
|
||||
}
|
||||
return newBox(key)
|
||||
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
key := make([]byte, 32)
|
||||
if _, randErr := rand.Read(key); randErr != nil {
|
||||
return nil, randErr
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(key)
|
||||
if writeErr := os.WriteFile(path, []byte(encoded+"\n"), 0o600); writeErr != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", path, writeErr)
|
||||
}
|
||||
return newBox(key)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newBox(key []byte) (*Box, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Box{aead: aead}, nil
|
||||
}
|
||||
|
||||
// Seal returns the sealed form of v. An empty string stays empty: there is nothing
|
||||
// to hide, and a row full of ciphertext for "" only makes the column harder to read.
|
||||
func (b *Box) Seal(v string) (string, error) {
|
||||
if v == "" || IsSealed(v) {
|
||||
return v, nil
|
||||
}
|
||||
nonce := make([]byte, b.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := b.aead.Seal(nonce, nonce, []byte(v), nil)
|
||||
return prefix + base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// Unseal returns the plain value. Anything without the marker is returned as it
|
||||
// is — the column holds values written before there was a key, and they are read
|
||||
// as themselves until something writes them back sealed.
|
||||
func (b *Box) Unseal(v string) (string, error) {
|
||||
if !IsSealed(v) {
|
||||
return v, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(v, prefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sealed value is not base64: %w", err)
|
||||
}
|
||||
if len(raw) < b.aead.NonceSize() {
|
||||
return "", errors.New("sealed value is too short to contain a nonce")
|
||||
}
|
||||
nonce, body := raw[:b.aead.NonceSize()], raw[b.aead.NonceSize():]
|
||||
out, err := b.aead.Open(nil, nonce, body, nil)
|
||||
if err != nil {
|
||||
// The overwhelmingly likely cause, and worth saying rather than "message
|
||||
// authentication failed", which sends people looking for a corrupt row.
|
||||
return "", errors.New("cannot read a stored secret: this is a different key " +
|
||||
"than the one it was written with")
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// IsSealed reports whether v has already been through Seal.
|
||||
func IsSealed(v string) bool { return strings.HasPrefix(v, prefix) }
|
||||
@@ -0,0 +1,149 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAValueSurvivesTheRoundTrip(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
for _, in := range []string{"1//0abc-refresh", "kodeord med æøå", strings.Repeat("x", 5000)} {
|
||||
sealed, err := b.Seal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(sealed, in) {
|
||||
t.Fatal("the sealed form still contains the value")
|
||||
}
|
||||
out, err := b.Unseal(sealed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Errorf("got %q, want %q", out, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same value must not look alike, or the column tells anyone
|
||||
// reading it which accounts share a password.
|
||||
func TestSealingTwiceGivesDifferentText(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
first, _ := b.Seal("same")
|
||||
second, _ := b.Seal("same")
|
||||
if first == second {
|
||||
t.Error("the same value sealed to the same text twice")
|
||||
}
|
||||
}
|
||||
|
||||
// The column holds values written before there was a key. Reading must not fail
|
||||
// on them, and sealing must not wrap an already-sealed value a second time.
|
||||
func TestPlainTextIsReadAsItself(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
out, err := b.Unseal("1//0abc-written-before-there-was-a-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "1//0abc-written-before-there-was-a-key" {
|
||||
t.Errorf("an unsealed value came back as %q", out)
|
||||
}
|
||||
|
||||
sealed, _ := b.Seal("x")
|
||||
again, _ := b.Seal(sealed)
|
||||
if again != sealed {
|
||||
t.Error("an already-sealed value was sealed a second time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsStillNothing(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
if out, _ := b.Seal(""); out != "" {
|
||||
t.Errorf("an empty value sealed to %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The message somebody gets after restoring a database without its key file. It
|
||||
// has to point at the key, because every other symptom points at the mailbox.
|
||||
func TestTheWrongKeySaysItIsTheWrongKey(t *testing.T) {
|
||||
sealed, err := mustBox(t).Seal("hemmelig")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = mustBox(t).Unseal(sealed)
|
||||
if err == nil {
|
||||
t.Fatal("another key opened it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "different key") {
|
||||
t.Errorf("the error does not name the cause: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeyFileIsMadeOnceAndKeptPrivate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Open("", dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, KeyFile)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("the key file is %04o, not 0600", perm)
|
||||
}
|
||||
|
||||
// Opening again must reuse it. A second key here would quietly make every
|
||||
// stored token unreadable, and it would look like the mailboxes disconnecting
|
||||
// themselves overnight.
|
||||
first, _ := os.ReadFile(path)
|
||||
if _, err := Open("", dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := os.ReadFile(path)
|
||||
if string(first) != string(second) {
|
||||
t.Error("opening a second time replaced the key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyOthersCanReadIsRefused(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Open("", dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, KeyFile)
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Open("", dir); err == nil {
|
||||
t.Error("a world-readable key was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The environment wins, so a host can keep the key off its disks altogether.
|
||||
func TestTheEnvironmentBeatsTheFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("k", 32)))
|
||||
if _, err := Open(key, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, KeyFile)); !os.IsNotExist(err) {
|
||||
t.Error("a key file was written even though the environment carried one")
|
||||
}
|
||||
|
||||
if _, err := Open("too-short", dir); err == nil {
|
||||
t.Error("a key that is not 32 bytes was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func mustBox(t *testing.T) *Box {
|
||||
t.Helper()
|
||||
b, err := Open("", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,253 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mailbox is one connected mailbox belonging to one person.
|
||||
type Mailbox struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Kind string `json:"kind"` // "gmail" or "imap"
|
||||
Name string `json:"name"`
|
||||
|
||||
Host string `json:"host,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"-"` // never leaves the server
|
||||
Folder string `json:"folder,omitempty"`
|
||||
|
||||
RefreshToken string `json:"-"`
|
||||
AccessToken string `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
Label string `json:"label,omitempty"`
|
||||
|
||||
// ProjectID is where this mailbox's tasks land. Empty means the inbox, and so
|
||||
// does an id the person can no longer write to.
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Trigger string `json:"trigger,omitempty"` // "starred" or "label"
|
||||
|
||||
// Seen is the message ids Gmail has already turned into tasks. IMAP does not
|
||||
// need it — uids are monotonic, so LastUID says the same in one number — but
|
||||
// Gmail's query is a rolling window rather than a marker.
|
||||
Seen []string `json:"-"`
|
||||
|
||||
LastUID uint32 `json:"last_uid,omitempty"`
|
||||
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
const mailboxColumns = `id, user_id, kind, name, host, username, password, folder,
|
||||
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
|
||||
last_uid, last_sync_at, created_at`
|
||||
|
||||
// Mailboxes returns everything one person has connected, oldest first.
|
||||
func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? ORDER BY created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Mailbox
|
||||
for rows.Next() {
|
||||
m, err := db.scanMailbox(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Mailbox returns one, and only to the person it belongs to: the id is enough to
|
||||
// find a row, so the owner is part of the question rather than checked after.
|
||||
func (db *DB) Mailbox(ctx context.Context, userID, id string) (*Mailbox, error) {
|
||||
row := db.QueryRowContext(ctx,
|
||||
`SELECT `+mailboxColumns+` FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
|
||||
m, err := db.scanMailbox(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// MailboxOfKind returns a person's mailbox of one kind, or nil. Gmail is one per
|
||||
// person because the OAuth flow is: connecting again replaces what was there.
|
||||
func (db *DB) MailboxOfKind(ctx context.Context, userID, kind string) (*Mailbox, error) {
|
||||
row := db.QueryRowContext(ctx,
|
||||
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? AND kind = ?`, userID, kind)
|
||||
m, err := db.scanMailbox(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// SaveMailbox writes one, sealing the two columns that must not travel with a
|
||||
// backup. Called by hand rather than by the settings tables' own seal, which does
|
||||
// not reach this table.
|
||||
func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
if m.CreatedAt.IsZero() {
|
||||
m.CreatedAt = time.Now()
|
||||
}
|
||||
if m.Folder == "" {
|
||||
m.Folder = "INBOX"
|
||||
}
|
||||
|
||||
password, err := db.sealValue(m.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal password: %w", err)
|
||||
}
|
||||
refresh, err := db.sealValue(m.RefreshToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal refresh_token: %w", err)
|
||||
}
|
||||
access, err := db.sealValue(m.AccessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal access_token: %w", err)
|
||||
}
|
||||
// Not sealed: which mails have been read is not a secret, and a column of
|
||||
// ciphertext here would only make the table harder to look at.
|
||||
seen, err := json.Marshal(m.Seen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Trigger == "" {
|
||||
m.Trigger = "starred"
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO mailboxes (`+mailboxColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
host = excluded.host,
|
||||
username = excluded.username,
|
||||
password = excluded.password,
|
||||
folder = excluded.folder,
|
||||
refresh_token = excluded.refresh_token,
|
||||
access_token = excluded.access_token,
|
||||
expires_at = excluded.expires_at,
|
||||
label = excluded.label,
|
||||
trigger_kind = excluded.trigger_kind,
|
||||
seen = excluded.seen,
|
||||
project_id = excluded.project_id,
|
||||
last_uid = excluded.last_uid,
|
||||
last_sync_at = excluded.last_sync_at`,
|
||||
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
|
||||
refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen), m.ProjectID,
|
||||
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkMailboxRead records how far a run got. Written separately from the rest so
|
||||
// a sync never rewrites the credentials it is holding in memory.
|
||||
func (db *DB) MarkMailboxRead(ctx context.Context, id string, lastUID uint32, at time.Time) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE mailboxes SET last_uid = ?, last_sync_at = ? WHERE id = ?`,
|
||||
lastUID, at.Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMailbox disconnects one. The tasks it made stay: they are the person's
|
||||
// work now, not the mailbox's.
|
||||
func (db *DB) DeleteMailbox(ctx context.Context, userID, id string) error {
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsersWithMailboxes returns everybody the sweep has to visit.
|
||||
func (db *DB) UsersWithMailboxes(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM mailboxes`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
|
||||
var m Mailbox
|
||||
var expires, lastSync, created int64
|
||||
var seen string
|
||||
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
|
||||
&m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires,
|
||||
&m.Label, &m.Trigger, &seen, &m.ProjectID, &m.LastUID, &lastSync, &created)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
// A row that somehow holds nonsense here is not worth failing a whole sync
|
||||
// over: an empty list means everything looks new, which is recoverable.
|
||||
_ = json.Unmarshal([]byte(seen), &m.Seen)
|
||||
|
||||
for _, field := range []*string{&m.Password, &m.RefreshToken, &m.AccessToken} {
|
||||
plain, err := db.unsealValue(*field)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
*field = plain
|
||||
}
|
||||
|
||||
m.ExpiresAt = timeOrZero(expires)
|
||||
m.LastSyncAt = timeOrZero(lastSync)
|
||||
m.CreatedAt = time.Unix(created, 0)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// sealValue and unsealValue are the single-string versions of the map helpers in
|
||||
// notifications.go. Without a key they pass the value through, which is what the
|
||||
// tests that never set one expect.
|
||||
func (db *DB) sealValue(v string) (string, error) {
|
||||
if db.box == nil {
|
||||
return v, nil
|
||||
}
|
||||
return db.box.Seal(v)
|
||||
}
|
||||
|
||||
func (db *DB) unsealValue(v string) (string, error) {
|
||||
if db.box == nil {
|
||||
return v, nil
|
||||
}
|
||||
return db.box.Unseal(v)
|
||||
}
|
||||
|
||||
func unixOrZero(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func timeOrZero(unix int64) time.Time {
|
||||
if unix == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(unix, 0)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/secret"
|
||||
)
|
||||
|
||||
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const password = "abcd-efgh-ijkl-mnop"
|
||||
m := &Mailbox{
|
||||
UserID: userID,
|
||||
Kind: "imap",
|
||||
Name: "iCloud",
|
||||
Host: "imap.mail.me.com:993",
|
||||
Username: "[email protected]",
|
||||
Password: password,
|
||||
}
|
||||
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stored string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT password FROM mailboxes WHERE id = ?`, m.ID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(stored, password) {
|
||||
t.Fatalf("the password is in the row as typed: %s", stored)
|
||||
}
|
||||
|
||||
back, err := db.Mailbox(ctx, userID, m.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back == nil {
|
||||
t.Fatal("the mailbox was not found")
|
||||
}
|
||||
if back.Password != password {
|
||||
t.Errorf("got password %q", back.Password)
|
||||
}
|
||||
if back.Folder != "INBOX" {
|
||||
t.Errorf("the folder defaulted to %q, want INBOX", back.Folder)
|
||||
}
|
||||
}
|
||||
|
||||
// The question that prompted the table: two people on one instance, each with
|
||||
// their own mailbox, neither seeing the other's.
|
||||
func TestAMailboxBelongsToOnePerson(t *testing.T) {
|
||||
db, kristian := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
andreas := &User{Email: "[email protected]", Name: "Andreas"}
|
||||
if err := db.CreateUser(ctx, andreas, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mine := &Mailbox{UserID: kristian, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||
theirs := &Mailbox{UserID: andreas.ID, Kind: "imap", Host: "imap.fastmail.com:993", Username: "andreas"}
|
||||
for _, m := range []*Mailbox{mine, theirs} {
|
||||
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := db.Mailboxes(ctx, kristian)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Username != "kw" {
|
||||
t.Fatalf("got %d mailboxes for Kristian, want only his own", len(list))
|
||||
}
|
||||
|
||||
// And the id alone is not enough to reach somebody else's.
|
||||
other, err := db.Mailbox(ctx, kristian, theirs.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other != nil {
|
||||
t.Error("one person read another's mailbox by its id")
|
||||
}
|
||||
}
|
||||
|
||||
// Several mailboxes each is the ordinary case — a work Gmail and a private
|
||||
// iCloud — and is the whole reason this is a table rather than a setting.
|
||||
func TestOnePersonCanHaveSeveral(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, host := range []string{"imap.mail.me.com:993", "imap.fastmail.com:993"} {
|
||||
if err := db.SaveMailbox(ctx, &Mailbox{
|
||||
UserID: userID, Kind: "imap", Host: host, Username: "kw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
list, err := db.Mailboxes(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("got %d mailboxes, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// The same mailbox twice would make two tasks out of every mail.
|
||||
func TestTheSameMailboxTwiceIsRefused(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||
if err := db.SaveMailbox(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||
if err := db.SaveMailbox(ctx, again); err == nil {
|
||||
t.Error("the same host and username was accepted a second time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadingProgressIsRemembered(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Now().Truncate(time.Second)
|
||||
if err := db.MarkMailboxRead(ctx, m.ID, 4711, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
back, err := db.Mailbox(ctx, userID, m.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.LastUID != 4711 {
|
||||
t.Errorf("last uid is %d", back.LastUID)
|
||||
}
|
||||
if !back.LastSyncAt.Equal(at) {
|
||||
t.Errorf("last sync is %v, want %v", back.LastSyncAt, at)
|
||||
}
|
||||
// The credentials it was holding must not have been rewritten as a side effect.
|
||||
if back.Host != "imap.mail.me.com:993" {
|
||||
t.Errorf("the host changed to %q", back.Host)
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the mailbox, not the work it produced.
|
||||
func TestDisconnectingKeepsTheTasks(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.DeleteMailbox(ctx, userID, m.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := db.Mailboxes(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("the mailbox is still connected")
|
||||
}
|
||||
}
|
||||
|
||||
// The migration that this whole move stands or falls on: a Gmail connected before
|
||||
// there was a mailboxes table must come out the other side still connected, with
|
||||
// its token readable and its settings intact. Nobody should have to reconnect
|
||||
// because the server tidied up.
|
||||
func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
box, err := secret.Open("", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A database at the state before 0017, with a sealed token written the way the
|
||||
// settings table wrote them.
|
||||
db, userID := storeAt(t, dir, box)
|
||||
sealed, err := box.Seal("1//0-the-original-refresh-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
||||
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||
VALUES (?, 'gmail', ?, 0)
|
||||
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
|
||||
userID, values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 0017 has already run on this database, so the row is moved by hand here in
|
||||
// the same statement the migration uses. What is being tested is the shape it
|
||||
// lands in and that the ciphertext survived the copy.
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO mailboxes (id, user_id, kind, name, host, username, password, folder,
|
||||
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
|
||||
last_uid, last_sync_at, created_at)
|
||||
SELECT lower(hex(randomblob(16))), s.user_id, 'gmail',
|
||||
json_extract(s.values_json, '$.email'), '', json_extract(s.values_json, '$.email'),
|
||||
'', 'INBOX', json_extract(s.values_json, '$.refresh_token'), '', 0,
|
||||
json_extract(s.values_json, '$.label'), json_extract(s.values_json, '$.trigger'),
|
||||
json_extract(s.values_json, '$.seen'), json_extract(s.values_json, '$.project_id'),
|
||||
0, 0, unixepoch()
|
||||
FROM user_settings s WHERE s.scope = 'gmail'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
moved, err := db.MailboxOfKind(context.Background(), userID, "gmail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved == nil {
|
||||
t.Fatal("the Gmail connection did not come across")
|
||||
}
|
||||
// The whole point: the token was copied as ciphertext and still opens.
|
||||
if moved.RefreshToken != "1//0-the-original-refresh-token" {
|
||||
t.Errorf("the token came out as %q", moved.RefreshToken)
|
||||
}
|
||||
if moved.Trigger != "label" || moved.Label != "Verdande" {
|
||||
t.Errorf("the trigger came out as %q/%q", moved.Trigger, moved.Label)
|
||||
}
|
||||
if moved.Username != "[email protected]" {
|
||||
t.Errorf("the address came out as %q", moved.Username)
|
||||
}
|
||||
if moved.ProjectID != "p-1" {
|
||||
t.Errorf("the destination project came out as %q", moved.ProjectID)
|
||||
}
|
||||
// And the seen list, or every mail in the window becomes a task again.
|
||||
if len(moved.Seen) != 2 || moved.Seen[0] != "abc" {
|
||||
t.Errorf("the seen list came out as %v", moved.Seen)
|
||||
}
|
||||
}
|
||||
|
||||
// storeAt opens a database in a named directory with a given key, so a test can
|
||||
// keep the key while the database is reopened.
|
||||
func storeAt(t *testing.T, dir string, box *secret.Box) (*DB, string) {
|
||||
t.Helper()
|
||||
db, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
db.UseSecrets(box)
|
||||
|
||||
user := &User{Email: "[email protected]", Name: "Kristian"}
|
||||
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, user.ID
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Passkeys: a key on a device instead of a secret in a head.
|
||||
--
|
||||
-- The account already has two ways in — a password, and a TOTP code on top of it.
|
||||
-- Both are shared secrets: the server keeps something that can be stolen from it,
|
||||
-- and the person keeps something that can be phished out of them. A passkey is
|
||||
-- neither. The private half never leaves the device, the public half is all this
|
||||
-- table holds, and the signature is bound to the origin — so a convincing copy of
|
||||
-- the sign-in page at a different address gets nothing.
|
||||
--
|
||||
-- One row per credential rather than per account, because a person has more than
|
||||
-- one device and losing a laptop should not mean losing the way in. The name is
|
||||
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
|
||||
-- list nobody can read is a list nobody revokes from.
|
||||
--
|
||||
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
|
||||
-- a counter that goes backwards means the credential has been cloned, which is
|
||||
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
|
||||
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
|
||||
CREATE TABLE passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
-- The credential id the authenticator generated, raw bytes, base64url as
|
||||
-- stored. Unique across the instance: it is what a login is looked up by,
|
||||
-- before anybody has said who they are.
|
||||
credential_id TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
-- The AAGUID identifies the authenticator model, which is what lets a list say
|
||||
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- Whether the credential lives on the device itself (a resident key). Only
|
||||
-- those can start a login with no email typed first, so the interface has to
|
||||
-- know which it has before it offers that.
|
||||
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
|
||||
-- face. A key that only proves possession is a first factor; one that verified
|
||||
-- its owner is both, and that is what decides whether a password is still
|
||||
-- asked for.
|
||||
user_verified INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
|
||||
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
|
||||
|
||||
-- The challenge a registration or a login is answered against.
|
||||
--
|
||||
-- Kept server-side rather than in a cookie: the whole point of the challenge is
|
||||
-- that the server chose it and remembers choosing it. One row per attempt, deleted
|
||||
-- when it is answered, and swept when it expires — a challenge that outlives its
|
||||
-- ceremony is a replay waiting to be tried.
|
||||
--
|
||||
-- user_id is null for a login that has not said who it is yet, which is the case
|
||||
-- a discoverable credential exists for.
|
||||
CREATE TABLE webauthn_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
|
||||
challenge TEXT NOT NULL,
|
||||
-- 'register' or 'login'. A challenge issued for one must not be answerable by
|
||||
-- the other: registering is done by somebody already signed in, and logging in
|
||||
-- is not.
|
||||
purpose TEXT NOT NULL,
|
||||
-- The library's own session state, which carries the allowed credentials and
|
||||
-- the verification requirement it will check the answer against.
|
||||
session BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
CHECK (purpose IN ('register', 'login'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Mailboxes: one row per connected mailbox, not one setting per person.
|
||||
--
|
||||
-- Gmail lived in `user_settings` under scope 'gmail'. That table has `user_id` as
|
||||
-- its whole primary key, so it holds exactly one scope per person and its upsert
|
||||
-- rewrites the scope name on conflict — adding IMAP beside Gmail there would have
|
||||
-- deleted the Gmail connection without saying so. Migration 0011 warns about this
|
||||
-- in as many words, and the warning was nearly walked into a second time.
|
||||
--
|
||||
-- The shape is wrong for the thing anyway. A person has mailboxes, plural: a work
|
||||
-- Gmail and a private iCloud is the ordinary case, not the exotic one. A row each
|
||||
-- says that. It also answers the question that prompted this — every mailbox
|
||||
-- belongs to a user, so two people on the same instance connect their own and see
|
||||
-- only their own. The one piece that stays instance-wide is the Google OAuth
|
||||
-- client, which is a single registration everybody signs in through, and that is
|
||||
-- correct: it is the app, not the account.
|
||||
--
|
||||
-- `kind` rather than two tables. What differs is how the credential is obtained —
|
||||
-- OAuth for Gmail, a password for IMAP — and what differs is a nullable column,
|
||||
-- not a schema. What is the same is everything the sync loop touches: which
|
||||
-- mailbox, how far it has read, when it last ran.
|
||||
--
|
||||
-- `password` and `refresh_token` are written sealed; see internal/secret. The
|
||||
-- store's own seal/unseal only covers the settings tables, so this table's methods
|
||||
-- call them by hand — a fact worth knowing before adding a third secret column.
|
||||
--
|
||||
-- `last_uid` is IMAP's place-marker and means nothing for Gmail, which keeps its
|
||||
-- own by query. Zero is "nothing read yet" for both.
|
||||
CREATE TABLE mailboxes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('gmail', 'imap')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- IMAP
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
folder TEXT NOT NULL DEFAULT 'INBOX',
|
||||
|
||||
-- Gmail
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
|
||||
last_uid INTEGER NOT NULL DEFAULT 0,
|
||||
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The sweep asks "whose mailboxes are due", so it reads by user.
|
||||
CREATE INDEX mailboxes_by_user ON mailboxes (user_id);
|
||||
|
||||
-- The same mailbox twice is a mistake, not a choice: two rows would make two
|
||||
-- tasks out of every mail. Gmail's uniqueness is the account, IMAP's is the host
|
||||
-- and the username together, and `kind` keeps the two from colliding.
|
||||
CREATE UNIQUE INDEX mailboxes_once ON mailboxes (user_id, kind, host, username);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Room for Gmail beside the others.
|
||||
--
|
||||
-- Only the two columns. Moving the data is a separate migration on purpose: the
|
||||
-- Gmail handlers still read user_settings, and a migration that emptied that table
|
||||
-- before they were rewritten would disconnect a working mailbox. Additive first,
|
||||
-- and the move when the code on the other side is ready for it.
|
||||
--
|
||||
-- Gmail lived in `user_settings` under scope 'gmail' because it was the only
|
||||
-- integration of its kind. Now there are two, and two places to look for the same
|
||||
-- question — which mailboxes does this person have — is one place too many.
|
||||
--
|
||||
-- `seen` is Gmail's own deduplication: a list of message ids it has already turned
|
||||
-- into tasks. IMAP does not need it, because uids are monotonic and `last_uid`
|
||||
-- answers the same question in one number. Gmail's query is a 30-day window rather
|
||||
-- than a marker, so the list is what it has.
|
||||
ALTER TABLE mailboxes ADD COLUMN seen TEXT NOT NULL DEFAULT '[]';
|
||||
|
||||
-- What makes a mail into a task: 'starred', or 'label' with the name in `label`.
|
||||
-- Its own column rather than packed into `label` with a separator — a column that
|
||||
-- holds two things joined by a colon is a column somebody parses wrongly later,
|
||||
-- and a label named with a colon in it would do it on the first day.
|
||||
ALTER TABLE mailboxes ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'starred';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Where a mailbox's tasks land.
|
||||
--
|
||||
-- Gmail carried this as `project_id` in its settings blob. It belongs to the
|
||||
-- mailbox, and it is worth having for the ones read over IMAP too: a work mailbox
|
||||
-- filing into the work project is the obvious thing to want, and the alternative
|
||||
-- is everything landing in the inbox to be moved by hand.
|
||||
--
|
||||
-- Empty means the person's inbox, which is also what an id that no longer names a
|
||||
-- project they can write to falls back to. Not a foreign key for that reason: a
|
||||
-- deleted project should quietly stop being the destination, not refuse the delete
|
||||
-- or take the mailbox down with it.
|
||||
ALTER TABLE mailboxes ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Gmail's rows move in beside the ones read over IMAP.
|
||||
--
|
||||
-- Held back until now on purpose: this empties the settings row, and running it
|
||||
-- while the handlers still read that row would have disconnected a working
|
||||
-- mailbox. internal/httpapi now reads the mailboxes table, so the move is safe.
|
||||
--
|
||||
-- The values move across still sealed. Both tables seal with the same key and
|
||||
-- store the same string, so copying the ciphertext verbatim is not a shortcut
|
||||
-- around the encryption — it is the encryption, untouched. That also means this
|
||||
-- migration does not need the key, which a migration has no business holding.
|
||||
--
|
||||
-- `username` is the address the account reports, and it is what makes the unique
|
||||
-- index meaningful. It may be empty on rows that never got that far; two such rows
|
||||
-- cannot exist for one person anyway, because the settings table could only hold
|
||||
-- one.
|
||||
INSERT INTO mailboxes (
|
||||
id, user_id, kind, name, host, username, password, folder,
|
||||
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
|
||||
last_uid, last_sync_at, created_at
|
||||
)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
s.user_id,
|
||||
'gmail',
|
||||
COALESCE(json_extract(s.values_json, '$.email'), 'Gmail'),
|
||||
'',
|
||||
COALESCE(json_extract(s.values_json, '$.email'), ''),
|
||||
'',
|
||||
'INBOX',
|
||||
COALESCE(json_extract(s.values_json, '$.refresh_token'), ''),
|
||||
COALESCE(json_extract(s.values_json, '$.access_token'), ''),
|
||||
COALESCE(json_extract(s.values_json, '$.expires_at'), 0),
|
||||
COALESCE(json_extract(s.values_json, '$.label'), ''),
|
||||
COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'),
|
||||
COALESCE(json_extract(s.values_json, '$.seen'), '[]'),
|
||||
COALESCE(json_extract(s.values_json, '$.project_id'), ''),
|
||||
0,
|
||||
0,
|
||||
unixepoch()
|
||||
FROM user_settings s
|
||||
WHERE s.scope = 'gmail'
|
||||
AND COALESCE(json_extract(s.values_json, '$.refresh_token'), '') <> '';
|
||||
|
||||
-- Emptied, not deleted: the row may hold nothing else today, but the table is
|
||||
-- shared and a DELETE here would be a habit worth not forming.
|
||||
UPDATE user_settings SET values_json = '{}' WHERE scope = 'gmail';
|
||||