Compare commits
@@ -79,12 +79,18 @@ jobs:
|
|||||||
working-directory: web
|
working-directory: web
|
||||||
run: npm ci --no-audit --no-fund
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
# Chromium only. A smoke test exists to catch a build that does not work at
|
# Chromium for the suite, WebKit for one test.
|
||||||
# all; running it three times in three engines finds the same breakage three
|
#
|
||||||
# times and costs three times as much.
|
# A smoke test exists to catch a build that does not work at all, and running
|
||||||
- name: Install Chromium
|
# 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
|
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
|
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||||
# playwright.config.js. There is nothing to build here first.
|
# playwright.config.js. There is nothing to build here first.
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ on:
|
|||||||
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
# write rather than read: the job creates the GitHub Release the update notice
|
||||||
|
# reads. packages: write is for pushing the image.
|
||||||
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -50,3 +52,15 @@ jobs:
|
|||||||
VERSION=${{ github.ref_name }}
|
VERSION=${{ github.ref_name }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# A tag is not a release. The in-app update notice reads GitHub's
|
||||||
|
# releases/latest, which answers 404 for a repository that only has tags —
|
||||||
|
# so without this step the check can never find anything, however correctly
|
||||||
|
# it is switched on.
|
||||||
|
#
|
||||||
|
# Created after the image, so a release never announces a version that
|
||||||
|
# failed to build.
|
||||||
|
- uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
make_latest: true
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
node_modules/
|
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/.svelte-kit/
|
||||||
/web/build/
|
/web/build/
|
||||||
|
|
||||||
@@ -40,3 +50,13 @@ node_modules/
|
|||||||
|
|
||||||
# Built documentation
|
# Built documentation
|
||||||
/site_build/
|
/site_build/
|
||||||
|
|
||||||
|
# Vite writes these while bundling vite.config.js and removes them again — unless
|
||||||
|
# the process is killed, which during a hung build is exactly what happens. One
|
||||||
|
# was committed by accident.
|
||||||
|
/web/vite.config.js.timestamp-*.mjs
|
||||||
|
|
||||||
|
# A compiled `go build ./tools/shots` in the repo root. It was committed once by
|
||||||
|
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
|
||||||
|
# it right back.
|
||||||
|
/shots
|
||||||
|
|||||||
@@ -5,7 +5,17 @@
|
|||||||
# manager and no libc — the attack surface of the running container is the binary.
|
# manager and no libc — the attack surface of the running container is the binary.
|
||||||
|
|
||||||
# --- 1. the web interface ----------------------------------------------------
|
# --- 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
|
WORKDIR /src
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 2. the binary -----------------------------------------------------------
|
# --- 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
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -37,11 +52,13 @@ COPY . .
|
|||||||
COPY --from=web /src/build ./cmd/verdande/webbuild
|
COPY --from=web /src/build ./cmd/verdande/webbuild
|
||||||
|
|
||||||
ARG VERSION=dev
|
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
|
# 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
|
# 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.
|
# 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 \
|
-tags embedweb \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
|||||||
@regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
|
@regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
|
||||||
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||||
views.
|
views, and foldable groups over the projects in the sidebar.
|
||||||
- **Sharing.** Internal users are created by an admin; anyone else joins through an
|
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||||
invite link sent to their email. Owner, editor and viewer roles.
|
a group, drop a task on another project, or drop it on another day.
|
||||||
|
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||||
|
everybody arrives through an invite link, either to a project or — from
|
||||||
|
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
|
||||||
|
by somebody else.
|
||||||
- **Live sync.** A change made by somebody else appears without a refresh.
|
- **Live sync.** A change made by somebody else appears without a refresh.
|
||||||
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
||||||
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
||||||
@@ -60,12 +64,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| Project roles and permission checks | Done, tested |
|
| Project roles and permission checks | Done, tested |
|
||||||
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
|
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
|
||||||
| Today, Upcoming, search, activity log | 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 |
|
| Sharing: invites, roles, members | Done, tested |
|
||||||
| Live sync over WebSocket | Done |
|
| Live sync over WebSocket | Done |
|
||||||
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
||||||
| Task detail: description, sub-tasks, comments, files, reminders | Done |
|
| Task detail: description, sub-tasks, comments, files, reminders | Done |
|
||||||
| Settings: account, notifications, integrations, AI, tokens, data | 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 to reorder, in both the list and the board | Done |
|
||||||
|
| Drag a task to another project or another day | Done, smoke-tested |
|
||||||
|
| Foldable project groups in the sidebar | Done, tested |
|
||||||
|
| Month view on Upcoming, not only per project | Done, smoke-tested |
|
||||||
| Recurring tasks (RRULE, Danish + English) | Done, tested |
|
| Recurring tasks (RRULE, Danish + English) | Done, tested |
|
||||||
| Reminders, absolute and relative | Done, tested |
|
| Reminders, absolute and relative | Done, tested |
|
||||||
| Saved filters with a query language | Done, tested |
|
| Saved filters with a query language | Done, tested |
|
||||||
@@ -102,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
|||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
| Board view — one column per section. | The light theme. |
|
| 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
|
## Running it
|
||||||
|
|
||||||
@@ -126,14 +149,6 @@ and the manifest ships in this repository. It runs anywhere Docker does as well.
|
|||||||
|
|
||||||
### With Docker directly
|
### With Docker directly
|
||||||
|
|
||||||
The package is private, and an anonymous pull fails with `No such image` rather
|
|
||||||
than with a permission error. Log the host's Docker daemon in once, with a GitHub
|
|
||||||
token carrying the `read:packages` scope:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name verdande \
|
docker run -d --name verdande \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
@@ -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_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
||||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
| `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. |
|
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
||||||
|
|
||||||
## What lives in `/data`
|
## What lives in `/data`
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ func run() error {
|
|||||||
|
|
||||||
errc := make(chan error, 1)
|
errc := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Info("listening", "addr", cfg.Addr)
|
// The version is repeated here rather than only on the "starting" line
|
||||||
|
// above: this is the line the Rune's done_regex matches, so it is the one
|
||||||
|
// sitting at the bottom of the panel console after a deploy — which is
|
||||||
|
// exactly when somebody wants to know what actually started.
|
||||||
|
log.Info("listening", "addr", cfg.Addr, "version", version)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
errc <- err
|
errc <- err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
|
|||||||
| `forbidden` | 403 | Cross-site request, or admin needed. |
|
| `forbidden` | 403 | Cross-site request, or admin needed. |
|
||||||
| `not_found` | 404 | Does not exist, or you cannot see it. |
|
| `not_found` | 404 | Does not exist, or you cannot see it. |
|
||||||
| `conflict` | 409 | Already exists, or the state does not allow it. |
|
| `conflict` | 409 | Already exists, or the state does not allow it. |
|
||||||
|
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
|
||||||
|
| `ai_not_configured` | 409 | No AI provider has been set up. |
|
||||||
|
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
|
||||||
|
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
|
||||||
| `validation_failed` | 422 | Check `fields`. |
|
| `validation_failed` | 422 | Check `fields`. |
|
||||||
| `rate_limited` | 429 | Too many attempts. |
|
| `rate_limited` | 429 | Too many attempts. |
|
||||||
| `internal_error` | 500 | Our fault. |
|
| `internal_error` | 500 | Our fault. |
|
||||||
@@ -96,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
|||||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||||
| `GET` | `/projects/{id}/activity` | |
|
| `GET` | `/projects/{id}/activity` | |
|
||||||
|
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||||
|
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||||
|
|
||||||
### Views, filters and labels
|
### Views, filters and labels
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
|||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
| `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 **Indstillinger → Notifikationer** 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. 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
|
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
|
at most every six hours and sends nothing about your instance — no identifier, no
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
||||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||||
board and calendar views.
|
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||||
|
- **Drag where it means something** — reorder a list or a board, file a project
|
||||||
|
under a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||||
joins through an invite link.
|
joins through an invite link.
|
||||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||||
|
|||||||
@@ -12,17 +12,40 @@ conversation.
|
|||||||
|
|
||||||
## Connecting it
|
## Connecting it
|
||||||
|
|
||||||
**Make a token.** **Settings → API tokens → New token**. It is shown once.
|
**Make a token** under **Settings → API tokens**. The page then shows the
|
||||||
|
connector address to go with it — token and all — which is the thing to copy:
|
||||||
|
|
||||||
**Add the connector.** In Claude, add a custom connector with:
|
```
|
||||||
|
https://todo.example.dk/mcp?key=vrd_…
|
||||||
|
```
|
||||||
|
|
||||||
- **URL**: `https://todo.example.dk/api/v1/mcp`
|
**Add the connector** in Claude with that address, and nothing else. The dialog
|
||||||
- **Authentication**: Bearer token, using the token you just made
|
asks for a name and a URL; leave the OAuth fields empty.
|
||||||
|
|
||||||
|
!!! note "Why the key is in the address"
|
||||||
|
The dialog has no field for a bearer token — only OAuth client credentials —
|
||||||
|
so a header cannot be configured from it. The address *is* the credential
|
||||||
|
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
|
||||||
|
Treat it like a password: anyone holding it reaches what you reach. The
|
||||||
|
server logs request paths without their query strings, so it does not end up
|
||||||
|
in the log.
|
||||||
|
|
||||||
!!! warning "It has to be reachable from the internet"
|
!!! warning "It has to be reachable from the internet"
|
||||||
Claude connects from Anthropic's side, not from your browser. An instance only
|
Claude connects from Anthropic's side, not from your browser. An instance only
|
||||||
reachable on your LAN or over a VPN cannot be used this way.
|
reachable on your LAN or over a VPN cannot be used this way.
|
||||||
|
|
||||||
|
### With a header instead
|
||||||
|
|
||||||
|
Anything that *can* send a header should, and use the API path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer vrd_…" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
||||||
|
https://todo.example.dk/api/v1/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Both endpoints run the same server and reach the same things.
|
||||||
|
|
||||||
## What Claude can do
|
## What Claude can do
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|
|||||||
@@ -3,33 +3,53 @@
|
|||||||
verdande ships with a Rune manifest for
|
verdande ships with a Rune manifest for
|
||||||
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
|
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
|
||||||
|
|
||||||
## Before you start: the registry is private
|
## If the image will not pull
|
||||||
|
|
||||||
The image lives at `ghcr.io/kristianwind/verdande` and the package is **private**,
|
|
||||||
so an anonymous pull fails with a message that reads as though the image does not
|
|
||||||
exist at all:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
create container: Error response from daemon:
|
create container: Error response from daemon:
|
||||||
No such image: ghcr.io/kristianwind/verdande:latest
|
No such image: ghcr.io/kristianwind/verdande:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
That is a permission error wearing the wrong hat. The Docker daemon **on the
|
This message is a liar. It does not mean the tag is missing from the registry —
|
||||||
Yggdrasil host** has to be logged in once:
|
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||||
|
without saying so.
|
||||||
|
|
||||||
|
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||||
|
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||||
|
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||||
|
looks locally — then reports the image as missing.
|
||||||
|
|
||||||
|
!!! warning "`docker login` on the host does not help"
|
||||||
|
`docker login` is client-side. The daemon stores no credentials; the CLI
|
||||||
|
sends them itself with each request. Yggdrasil is a different client and
|
||||||
|
sends none, so logging in changes nothing for the panel.
|
||||||
|
|
||||||
|
**Make the package public.** Under
|
||||||
|
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||||
|
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||||
|
readable, which is what the manifest and these pages have always assumed.
|
||||||
|
|
||||||
|
Once it is public, press **Start** on the server you already have. There is no
|
||||||
|
need to delete it and make another: the image reference is read from the rune at
|
||||||
|
container-create time, and both Start and Restart recreate the container.
|
||||||
|
|
||||||
|
To keep the package private you have to put the image on the host yourself,
|
||||||
|
before creating the server, and again after every release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
sudo docker login ghcr.io -u kristianwind
|
||||||
|
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
`GHCR_TOKEN` is a GitHub personal access token with the **`read:packages`** scope
|
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||||
and nothing else. The login is stored in `~/.docker/config.json` for whichever
|
panel's own pull still fails silently; create simply finds the image already
|
||||||
user runs the daemon — if Yggdrasil runs as root or through systemd, log in as
|
there.
|
||||||
that user, not as yourself.
|
|
||||||
|
|
||||||
!!! tip "Or make the package public"
|
!!! danger "This hides updates"
|
||||||
Under **Packages → verdande → Package settings → Change visibility**. The
|
The panel re-pulls on every recreate, fails quietly, and carries on with the
|
||||||
repository can stay private; only the built image becomes readable. Then no
|
local image. A new release then *looks* like it rolled out and did not. For
|
||||||
login is needed anywhere and this section stops applying.
|
something you intend to update, public is the right answer and host-pull is
|
||||||
|
the stopgap.
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -40,6 +60,16 @@ that user, not as yourself.
|
|||||||
3. Fill in the settings below.
|
3. Fill in the settings below.
|
||||||
4. Start it, open the URL, create the first account.
|
4. Start it, open the URL, create the first account.
|
||||||
|
|
||||||
|
!!! note "Updating the rune is a manual step"
|
||||||
|
A rune does not update itself when this manifest changes. The panel writes
|
||||||
|
the rune row from a UI action only — neither a restart nor a timer does it —
|
||||||
|
so pick up a new manifest with **Runes → verdande → Update**, or install it
|
||||||
|
again from Browse GitHub.
|
||||||
|
|
||||||
|
The catalogue list is cached in memory for ten minutes, so two attempts in a
|
||||||
|
row can both hand you the old version. Recent panel versions show the list's
|
||||||
|
age with a *fetch again* link.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
| Setting | What to put |
|
| Setting | What to put |
|
||||||
|
|||||||
|
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 |
@@ -5,15 +5,12 @@ cache, no queue and no second process.
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
!!! warning "Log in to the registry first"
|
!!! note "If the pull is refused"
|
||||||
The package is private, and an anonymous pull fails with `No such image`
|
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||||
rather than with a permission error. Once per host:
|
back to private, `docker login ghcr.io` with a token carrying the
|
||||||
|
`read:packages` scope before pulling — and see
|
||||||
```bash
|
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
|
fails differently and much more confusingly under Yggdrasil.
|
||||||
```
|
|
||||||
|
|
||||||
`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name verdande \
|
docker run -d --name verdande \
|
||||||
|
|||||||
@@ -3,21 +3,30 @@ module github.com/kristianwind/verdande
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
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/pquerna/otp v1.5.0
|
||||||
|
github.com/teambition/rrule-go v1.8.2
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
modernc.org/sqlite v1.56.0
|
modernc.org/sqlite v1.56.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
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/dustin/go-humanize v1.0.1 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // 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/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
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -2,12 +2,27 @@ 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/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 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
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.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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
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 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
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 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
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=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -18,6 +33,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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
@@ -25,10 +42,17 @@ 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/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.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 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
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=
|
||||||
|
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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
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/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
@@ -39,6 +63,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
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/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
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 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
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
|
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)
|
||||||
|
}
|
||||||
@@ -38,6 +38,22 @@ type Config struct {
|
|||||||
// the deal its operator made by self-hosting.
|
// the deal its operator made by self-hosting.
|
||||||
UpdateCheck bool
|
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.
|
||||||
|
PanelURL string
|
||||||
|
PanelToken string
|
||||||
|
PanelServerID string
|
||||||
|
|
||||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||||
TrashRetention time.Duration
|
TrashRetention time.Duration
|
||||||
|
|
||||||
@@ -69,6 +85,9 @@ func Load() (*Config, error) {
|
|||||||
Dev: envBool("VERDANDE_DEV", false),
|
Dev: envBool("VERDANDE_DEV", false),
|
||||||
|
|
||||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||||
|
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||||
|
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||||
|
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "anden@example.dk", "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, "anden@example.dk", "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(), "kristian@example.dk")
|
||||||
|
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, "anden@example.dk", "Anden")
|
||||||
|
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
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, "anden@example.dk", "Anden")
|
||||||
|
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
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": "anden@example.dk", "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"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
@@ -25,17 +27,62 @@ type userJSON struct {
|
|||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
TOTPEnabled bool `json:"totp_enabled"`
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
CreatedAt string `json:"created_at"`
|
// SidebarCollapsed is which sidebar headings this person has folded away. On
|
||||||
|
// the account rather than in the browser, because folding one is a statement
|
||||||
|
// about the work and not about the screen — the sidebar's width is the
|
||||||
|
// opposite case and stays in localStorage.
|
||||||
|
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toUserJSON(u *store.User) userJSON {
|
func toUserJSON(u *store.User) userJSON {
|
||||||
|
collapsed := u.SidebarCollapsed
|
||||||
|
// Never null in the response: a client that has to check for both an absent
|
||||||
|
// array and an empty one will one day check for only one of them.
|
||||||
|
if collapsed == nil {
|
||||||
|
collapsed = []string{}
|
||||||
|
}
|
||||||
return userJSON{
|
return userJSON{
|
||||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||||
TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
|
||||||
|
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSetSidebarSections records which sidebar headings are folded away.
|
||||||
|
//
|
||||||
|
// Its own route rather than a field on the profile: that one backs a form with a
|
||||||
|
// save button and validates three fields together, and this writes on every click
|
||||||
|
// of a chevron. Sharing the path would mean a fold sending a name and a timezone
|
||||||
|
// it was never asked to change — and a validation error on one of those failing a
|
||||||
|
// fold.
|
||||||
|
//
|
||||||
|
// The keys are the sidebar's and are not checked here. An unknown one costs
|
||||||
|
// nothing: the sidebar folds the headings it recognises and ignores the rest, so
|
||||||
|
// a heading that is renamed or removed leaves a harmless entry rather than a
|
||||||
|
// migration.
|
||||||
|
func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Sections []string `json:"sections"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A cap, because this is a list the client decides the length of. Twenty is far
|
||||||
|
// more headings than the sidebar has ever had.
|
||||||
|
if len(req.Sections) > 20 {
|
||||||
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many sections")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
if err := s.db.SetSidebarCollapsed(r.Context(), user.ID, req.Sections); err != nil {
|
||||||
|
s.storeError(w, r, "set sidebar sections", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// --- sign in -----------------------------------------------------------------
|
// --- sign in -----------------------------------------------------------------
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
@@ -88,7 +135,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create session", err)
|
s.internal(w, r, "create session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setSessionCookie(w, token, ttl)
|
s.setSessionCookie(w, token, ttl)
|
||||||
@@ -130,7 +177,7 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
// The cookie is reissued so its lifetime matches the session's new one.
|
// The cookie is reissued so its lifetime matches the session's new one.
|
||||||
@@ -210,13 +257,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := s.db.UserByID(r.Context(), user.ID)
|
updated, err := s.db.UserByID(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "reload user", err)
|
s.internal(w, r, "reload user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
||||||
@@ -263,7 +310,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,14 +324,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
"an account already exists for that email address")
|
"an account already exists for that email address")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.internal(w, "create user", err)
|
s.internal(w, r, "create user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepting the invite is what adds the membership and burns the token, in one
|
// 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.
|
// 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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +375,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +388,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.internal(w, "create first admin", err)
|
s.internal(w, r, "create first admin", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +402,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||||
n, err := s.db.UserCount(r.Context())
|
n, err := s.db.UserCount(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "count users", err)
|
s.internal(w, r, "count users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||||
@@ -364,7 +411,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
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)
|
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create session", err)
|
s.internal(w, r, "create session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
||||||
@@ -393,7 +440,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create password reset", err)
|
s.internal(w, r, "create password reset", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
link := s.cfg.BaseURL + "/reset?token=" + token
|
link := s.cfg.BaseURL + "/reset?token=" + token
|
||||||
@@ -431,13 +478,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Every session ends: a password reset is what somebody does when they think
|
// Every session ends: a password reset is what somebody does when they think
|
||||||
// another person has been in their account.
|
// another person has been in their account.
|
||||||
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +516,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
hash, err := auth.HashPassword(req.NewPassword)
|
hash, err := auth.HashPassword(req.NewPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "hash password", err)
|
s.internal(w, r, "hash password", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keep := ""
|
keep := ""
|
||||||
@@ -477,7 +524,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
keep = session.ID
|
keep = session.ID
|
||||||
}
|
}
|
||||||
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -520,7 +567,55 @@ func inboxName(locale string) string {
|
|||||||
// internal logs the real error and tells the client nothing about it. The detail
|
// 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
|
// 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.
|
// 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.
|
||||||
|
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: http.StatusBadGateway,
|
||||||
|
What: what, Message: err.Error(), UserID: userID,
|
||||||
|
RequestID: middleware.GetReqID(r.Context()),
|
||||||
|
})
|
||||||
|
|
||||||
|
writeError(w, http.StatusBadGateway, code, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
s.log.Error(what, "err", err)
|
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")
|
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() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
cfg := &config.Config{
|
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(),
|
DataDir: t.TempDir(),
|
||||||
SessionTTL: 24 * time.Hour,
|
SessionTTL: 24 * time.Hour,
|
||||||
InviteTTL: 7 * 24 * time.Hour,
|
InviteTTL: 7 * 24 * time.Hour,
|
||||||
@@ -761,3 +763,137 @@ func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
|
|||||||
t.Errorf("status %d, want 401", resp.StatusCode)
|
t.Errorf("status %d, want 401", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// last_seen_at has been written on every request since sessions existed, for
|
||||||
|
// exactly this list, and nothing read it. A session you cannot see is a session
|
||||||
|
// you cannot end.
|
||||||
|
func TestSessionsCanBeListedAndEnded(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// A second sign-in from "another device": same account, its own cookie jar.
|
||||||
|
other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||||
|
resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("second login: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("list sessions: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
sessions, _ := body["sessions"].([]any)
|
||||||
|
if len(sessions) != 2 {
|
||||||
|
t.Fatalf("want two sessions, got %v", sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly one is this one, or the interface cannot tell somebody which row is
|
||||||
|
// the browser they are reading it in.
|
||||||
|
current := ""
|
||||||
|
for _, raw := range sessions {
|
||||||
|
s := raw.(map[string]any)
|
||||||
|
if s["current"] == true {
|
||||||
|
if current != "" {
|
||||||
|
t.Fatal("two sessions both claim to be the current one")
|
||||||
|
}
|
||||||
|
current = s["id"].(string)
|
||||||
|
}
|
||||||
|
if s["device"] == "" {
|
||||||
|
t.Errorf("no device summary: %v", s)
|
||||||
|
}
|
||||||
|
if s["last_seen_at"] == nil {
|
||||||
|
t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
t.Fatal("none of the sessions is marked as the current one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// End the other one.
|
||||||
|
var otherID string
|
||||||
|
for _, raw := range sessions {
|
||||||
|
if s := raw.(map[string]any); s["id"] != current {
|
||||||
|
otherID = s["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete session: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// That device is signed out, and this one is not.
|
||||||
|
if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("the ended session still works: status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("ending another session logged this one out: status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's session is not yours to end, even with its id in hand.
|
||||||
|
func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
|
||||||
|
mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string)
|
||||||
|
|
||||||
|
if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// And it still works.
|
||||||
|
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("somebody else ended my session: status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An API token is accepted almost everywhere, and must not be accepted here: a
|
||||||
|
// leaked token that could end its owner's sessions, or even list their devices,
|
||||||
|
// turns a theft into a lockout.
|
||||||
|
func TestSessionsAreNotReachableWithAToken(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, user.ID)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("status %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ua string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"},
|
||||||
|
// An iPhone's user agent says Mac OS X too, so the order of the tests is
|
||||||
|
// the whole correctness of this function.
|
||||||
|
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"},
|
||||||
|
// And Edge claims to be Chrome, which claims to be Safari.
|
||||||
|
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"},
|
||||||
|
{"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"},
|
||||||
|
{"", "Ukendt enhed"},
|
||||||
|
// Not a browser at all. Its own name beats a word that says the server
|
||||||
|
// gave up: a CalDAV client is a real session.
|
||||||
|
{"curl/8.4.0", "curl/8.4.0"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := describeUserAgent(c.ua); got != c.want {
|
||||||
|
t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "anden@example.dk", "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(), "kristian@example.dk")
|
||||||
|
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)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "caldav home", err)
|
s.internal(w, r, "caldav home", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
if r.Header.Get("Depth") != "0" {
|
if r.Header.Get("Depth") != "0" {
|
||||||
tasks, err := s.caldavTasks(r, user, projectID)
|
tasks, err := s.caldavTasks(r, user, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "caldav tasks", err)
|
s.internal(w, r, "caldav tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, t := range tasks {
|
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)
|
tasks, err := s.caldavTasks(r, user, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "caldav report", err)
|
s.internal(w, r, "caldav report", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
byID := map[string]store.Task{}
|
byID := map[string]store.Task{}
|
||||||
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
update.DueDatetime = parsed.DueDatetime
|
update.DueDatetime = parsed.DueDatetime
|
||||||
|
|
||||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if parsed.Completed && !existing.Completed() {
|
if parsed.Completed && !existing.Completed() {
|
||||||
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
task.Content = "(uden titel)"
|
task.Content = "(uden titel)"
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||||
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||||
s.internal(w, "caldav delete", err)
|
s.internal(w, r, "caldav delete", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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)
|
comments, err := s.db.ListComments(r.Context(), taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list comments", err)
|
s.internal(w, r, "list comments", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]commentJSON, 0, len(comments))
|
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)
|
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list attachments", err)
|
s.internal(w, r, "list attachments", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
direct := make([]attachmentJSON, 0, len(files))
|
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)
|
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create comment", err)
|
s.internal(w, r, "create comment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
|
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)
|
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "update comment", err)
|
s.storeError(w, r, "update comment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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)
|
taskID, err := s.db.CommentTask(r.Context(), commentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "comment task", err)
|
s.storeError(w, r, "comment task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
stored, size, err := s.storeUpload(file)
|
stored, size, err := s.storeUpload(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "store upload", err)
|
s.internal(w, r, "store upload", err)
|
||||||
return
|
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 {
|
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||||
s.internal(w, "record attachment", err)
|
s.internal(w, r, "record attachment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,23 +300,55 @@ func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
|
|||||||
return rel, size, nil
|
return rel, size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
// reachAttachment answers whether this person may see an attachment at all, and
|
||||||
attachmentID := chi.URLParam(r, "attachmentID")
|
// 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())
|
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)
|
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "attachment task", err)
|
return false, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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")
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get attachment", err)
|
s.storeError(w, r, "get attachment", err)
|
||||||
return
|
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) {
|
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
attachmentID := chi.URLParam(r, "attachmentID")
|
attachmentID := chi.URLParam(r, "attachmentID")
|
||||||
user := userFrom(r.Context())
|
|
||||||
|
|
||||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
canEdit, err := s.reachAttachment(r, attachmentID)
|
||||||
if err != nil {
|
if err != nil || !canEdit {
|
||||||
s.storeError(w, "attachment task", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
||||||
if err != nil || !role.CanEdit() {
|
|
||||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete attachment", err)
|
s.storeError(w, r, "delete attachment", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The comment above the code list in errors.go says they are kept in one place
|
||||||
|
// "so the frontend's mapping can be checked against it rather than discovered
|
||||||
|
// from whatever a handler happened to write". Nothing checked it, and the
|
||||||
|
// frontend was quietly missing entries — which is not a cosmetic gap: a code
|
||||||
|
// with no message falls through to the server's English log prose, and a code
|
||||||
|
// mapped to the wrong sentence tells the person something untrue. Connecting
|
||||||
|
// Gmail without an OAuth client said "Det er der allerede."
|
||||||
|
|
||||||
|
var (
|
||||||
|
codeConst = regexp.MustCompile(`(?m)^\s*Code\w+\s*=\s*"([a-z_]+)"`)
|
||||||
|
messageKey = regexp.MustCompile(`(?m)^\s*([a-z_]+):\s*$|^\s*([a-z_]+):\s*'`)
|
||||||
|
messagesTop = "const MESSAGES = {"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEveryErrorCodeHasAMessageInTheFrontend(t *testing.T) {
|
||||||
|
codes := serverErrorCodes(t)
|
||||||
|
messages := frontendMessageKeys(t)
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
for _, code := range codes {
|
||||||
|
if !messages[code] {
|
||||||
|
missing = append(missing, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(missing)
|
||||||
|
|
||||||
|
for _, code := range missing {
|
||||||
|
t.Errorf("%q is returned by the API and has no message in web/src/lib/api.js — "+
|
||||||
|
"a person would be shown the server's English log prose", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverErrorCodes(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
source, err := os.ReadFile("errors.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read errors.go: %v", err)
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
for _, m := range codeConst.FindAllStringSubmatch(string(source), -1) {
|
||||||
|
codes = append(codes, m[1])
|
||||||
|
}
|
||||||
|
if len(codes) == 0 {
|
||||||
|
t.Fatal("no codes were read out of errors.go — the scanner and the file disagree")
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontendMessageKeys reads the keys of the MESSAGES object. A small scanner
|
||||||
|
// rather than a JS parser, for the same reason the OpenAPI check uses one: it
|
||||||
|
// needs one thing out of a file this test also owns.
|
||||||
|
func frontendMessageKeys(t *testing.T) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
source, err := os.ReadFile(filepath.Join("..", "..", "web", "src", "lib", "api.js"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read api.js: %v", err)
|
||||||
|
}
|
||||||
|
body := string(source)
|
||||||
|
|
||||||
|
start := strings.Index(body, messagesTop)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("could not find %q in api.js", messagesTop)
|
||||||
|
}
|
||||||
|
body = body[start+len(messagesTop):]
|
||||||
|
end := strings.Index(body, "\n};")
|
||||||
|
if end < 0 {
|
||||||
|
t.Fatal("the MESSAGES object does not end where this scanner expects")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(body[:end], "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "//") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _, ok := strings.Cut(line, ":")
|
||||||
|
if ok {
|
||||||
|
keys[strings.TrimSpace(name)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
t.Fatal("no message keys were read out of api.js")
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four that used to be indistinguishable from "that already exists".
|
||||||
|
func TestTheMisleadingConflictsHaveTheirOwnCodes(t *testing.T) {
|
||||||
|
for _, code := range []string{
|
||||||
|
CodeGmailNotConfigured,
|
||||||
|
CodeAINotConfigured,
|
||||||
|
CodeTOTPNotEnabled,
|
||||||
|
CodeInboxProtected,
|
||||||
|
} {
|
||||||
|
if code == CodeConflict {
|
||||||
|
t.Errorf("%q is still plain conflict", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := frontendMessageKeys(t)
|
||||||
|
for _, code := range []string{"gmail_not_configured", "ai_not_configured",
|
||||||
|
"totp_not_enabled", "inbox_protected"} {
|
||||||
|
if !messages[code] {
|
||||||
|
t.Errorf("%q has no Danish message", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Inbox is the one project that cannot be deleted, and saying "that already
|
||||||
|
// exists" about it helps nobody.
|
||||||
|
func TestDeletingTheInboxSaysWhy(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
list, _ := projects["projects"].([]any)
|
||||||
|
|
||||||
|
var inboxID string
|
||||||
|
for _, raw := range list {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if p["is_inbox"] == true {
|
||||||
|
inboxID = p["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inboxID == "" {
|
||||||
|
t.Fatal("no inbox was found")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "DELETE", "/api/v1/projects/"+inboxID, nil)
|
||||||
|
if resp.StatusCode != 409 {
|
||||||
|
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["code"] != CodeInboxProtected {
|
||||||
|
t.Errorf("code = %v, want %q", body["code"], CodeInboxProtected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -36,8 +36,34 @@ const (
|
|||||||
CodeRateLimited = "rate_limited"
|
CodeRateLimited = "rate_limited"
|
||||||
CodeInternal = "internal_error"
|
CodeInternal = "internal_error"
|
||||||
CodePayloadTooLarge = "payload_too_large"
|
CodePayloadTooLarge = "payload_too_large"
|
||||||
|
|
||||||
|
// Four situations that used to answer plain `conflict`, which the frontend
|
||||||
|
// renders as "that already exists". For these that is not merely vague, it is
|
||||||
|
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
|
||||||
|
// second factor that is off is not one that is on. A code exists so the
|
||||||
|
// interface can say the true thing — the server's own prose is English for a
|
||||||
|
// log, and deciding what a person reads is the frontend's job.
|
||||||
|
CodeGmailNotConfigured = "gmail_not_configured"
|
||||||
|
CodeAINotConfigured = "ai_not_configured"
|
||||||
|
CodeTOTPNotEnabled = "totp_not_enabled"
|
||||||
|
CodeInboxProtected = "inbox_protected"
|
||||||
|
// And a fifth, for the same reason: refusing to remove the last administrator
|
||||||
|
// is not "that already exists" either, and it is the one refusal on the user
|
||||||
|
// page that a person needs explained rather than merely reported.
|
||||||
|
CodeLastAdmin = "last_admin"
|
||||||
|
|
||||||
|
// 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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) {
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||||
writeJSON(w, status, APIError{Code: code, Message: message})
|
writeJSON(w, status, APIError{Code: code, Message: message})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: 2000,
|
Limit: 2000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ics feed", err)
|
s.internal(w, r, "ics feed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ics feed projects", err)
|
s.internal(w, r, "ics feed projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
names := map[string]string{}
|
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)
|
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ics token", err)
|
s.internal(w, r, "ics token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
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()
|
token, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate feed token", err)
|
s.internal(w, r, "generate feed token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
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)
|
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list reminders", err)
|
s.internal(w, r, "list reminders", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]reminderJSON, 0, len(reminders))
|
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)
|
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create reminder", err)
|
s.internal(w, r, "create reminder", err)
|
||||||
return
|
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) {
|
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete reminder", err)
|
s.storeError(w, r, "delete reminder", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
|
|||||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||||
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list templates", err)
|
s.internal(w, r, "list templates", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]templateJSON, 0, len(templates))
|
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,
|
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
||||||
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "save template", err)
|
s.storeError(w, r, "save template", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
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"))
|
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get template", err)
|
s.storeError(w, r, "get template", err)
|
||||||
return
|
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)
|
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create from template", err)
|
s.internal(w, r, "create from template", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, project.ID, "", "project.created", map[string]any{
|
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) {
|
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete template", err)
|
s.storeError(w, r, "delete template", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
|
|||||||
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
||||||
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list filters", err)
|
s.internal(w, r, "list filters", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]filterJSON, 0, len(filters))
|
out := make([]filterJSON, 0, len(filters))
|
||||||
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
f.SortOrder = *req.SortOrder
|
f.SortOrder = *req.SortOrder
|
||||||
}
|
}
|
||||||
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
||||||
s.internal(w, "create filter", err)
|
s.internal(w, r, "create filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
|
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,
|
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "update filter", err)
|
s.storeError(w, r, "update filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete filter", err)
|
s.storeError(w, r, "delete filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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"))
|
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get filter", err)
|
s.storeError(w, r, "get filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.runQuery(w, r, f.Query)
|
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),
|
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "run filter", err)
|
s.internal(w, r, "run filter", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
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) {
|
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
|
||||||
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list labels", err)
|
s.internal(w, r, "list labels", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]labelJSON, 0, len(labels))
|
out := make([]labelJSON, 0, len(labels))
|
||||||
@@ -242,9 +242,10 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.internal(w, "create label", err)
|
s.internal(w, r, "create label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||||
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,17 +261,19 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.storeError(w, "update label", err)
|
s.storeError(w, r, "update label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.publishToOwner(r, "label.changed", nil)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteLabel(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"))
|
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete label", err)
|
s.storeError(w, r, "delete label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.publishToOwner(r, "label.changed", nil)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,44 @@ import (
|
|||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) gmailConfig() gmail.Config {
|
// gmailConfig is the OAuth client this instance authorises with.
|
||||||
return gmail.Config{
|
//
|
||||||
|
// The environment wins when it is set. A value in the environment is a deliberate
|
||||||
|
// deployment decision — it is in the Rune's manifest, under version control,
|
||||||
|
// applied on every recreate — and a form that could quietly override it would make
|
||||||
|
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
|
||||||
|
// to redeploy a container to paste a client id.
|
||||||
|
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||||
|
cfg := gmail.Config{
|
||||||
ClientID: s.cfg.GmailClientID,
|
ClientID: s.cfg.GmailClientID,
|
||||||
ClientSecret: s.cfg.GmailClientSecret,
|
ClientSecret: s.cfg.GmailClientSecret,
|
||||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||||
}
|
}
|
||||||
|
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := s.db.InstanceSettings(ctx, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
// Not fatal: an unconfigured Gmail and a database that could not answer
|
||||||
|
// look the same from here, and the caller's next step is the same either
|
||||||
|
// way — say it is not set up.
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
if id, _ := stored["client_id"].(string); cfg.ClientID == "" {
|
||||||
|
cfg.ClientID = id
|
||||||
|
}
|
||||||
|
if secret, _ := stored["client_secret"].(string); cfg.ClientSecret == "" {
|
||||||
|
cfg.ClientSecret = secret
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// gmailFromEnv says whether the deployment set the client, which the settings page
|
||||||
|
// needs so it can explain why the fields are not editable rather than accepting
|
||||||
|
// changes that will never take effect.
|
||||||
|
func (s *Server) gmailFromEnv() bool {
|
||||||
|
return s.cfg.GmailClientID != "" && s.cfg.GmailClientSecret != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
|
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
|
||||||
@@ -27,23 +59,23 @@ func (s *Server) gmailConfig() gmail.Config {
|
|||||||
// defaults.
|
// defaults.
|
||||||
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
cfg := s.gmailConfig()
|
cfg := s.gmailConfig(r.Context())
|
||||||
|
|
||||||
if !cfg.Configured() {
|
if !cfg.Configured() {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||||
"Gmail er ikke sat op på denne server — administratoren skal registrere en OAuth-klient hos Google")
|
"no Google OAuth client is registered on this server")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pkce, err := gmail.NewPKCE()
|
pkce, err := gmail.NewPKCE()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail pkce", err)
|
s.internal(w, r, "gmail pkce", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settings["pkce_verifier"] = pkce.Verifier
|
settings["pkce_verifier"] = pkce.Verifier
|
||||||
@@ -53,7 +85,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
|||||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||||
@@ -83,7 +115,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
verifier, _ := settings["pkce_verifier"].(string)
|
verifier, _ := settings["pkce_verifier"].(string)
|
||||||
@@ -102,7 +134,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
s.log.Error("gmail exchange", "err", err, "user", user.ID)
|
s.log.Error("gmail exchange", "err", err, "user", user.ID)
|
||||||
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
|
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
|
||||||
@@ -118,6 +150,9 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("gmail profile", "err", err)
|
s.log.Warn("gmail profile", "err", err)
|
||||||
@@ -138,7 +173,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||||
s.internal(w, "save gmail tokens", err)
|
s.internal(w, r, "save gmail tokens", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
||||||
@@ -152,7 +187,7 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
created, err := s.SyncGmail(r.Context(), user)
|
created, err := s.SyncGmail(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
||||||
@@ -185,7 +220,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
access := str("access_token")
|
access := str("access_token")
|
||||||
expires, _ := settings["expires_at"].(float64)
|
expires, _ := settings["expires_at"].(float64)
|
||||||
if access == "" || int64(expires) <= time.Now().Unix() {
|
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||||
token, err := s.gmailConfig().Refresh(ctx, refresh)
|
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -300,3 +335,132 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, status)
|
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, user *store.User, settings map[string]any) (string, error) {
|
||||||
|
str := func(key string) string {
|
||||||
|
v, _ := settings[key].(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
access := str("access_token")
|
||||||
|
expires, _ := settings["expires_at"].(float64)
|
||||||
|
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||||
|
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
access = token.AccessToken
|
||||||
|
settings["access_token"] = access
|
||||||
|
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err := gmail.NewClient(access).Profile(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
settings["email"] = email
|
||||||
|
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Connected, with a refresh token that will be refused.
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||||
|
"refresh_token": "spent", "trigger": "starred",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
|
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(http.StatusBadGateway) {
|
||||||
|
t.Errorf("status = %v, want 502", row["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
|
|||||||
project := todoist.FromRows(rows, name)
|
project := todoist.FromRows(rows, name)
|
||||||
result, err := s.importProject(r, user, project)
|
result, err := s.importProject(r, user, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "import project", err)
|
s.internal(w, r, "import project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, result)
|
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)
|
result, err := s.importProject(r, user, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "import csv", err)
|
s.internal(w, r, "import csv", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, result)
|
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)
|
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exported, err := s.buildExport(r, user, project)
|
exported, err := s.buildExport(r, user, project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export project", err)
|
s.internal(w, r, "export project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export projects", err)
|
s.internal(w, r, "export projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export tasks", err)
|
s.internal(w, r, "export tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, t := range tasks {
|
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)
|
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "export ics", err)
|
s.internal(w, r, "export ics", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import (
|
|||||||
|
|
||||||
type mailAddressResponse struct {
|
type mailAddressResponse struct {
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
// Configured says whether a mail server has been set up at all. Without one
|
||||||
|
// the address is still minted and still valid — the token is real, and the
|
||||||
|
// endpoint that receives mail works — but nothing on the internet routes to
|
||||||
|
// it yet, and an interface that shows the address without saying so is
|
||||||
|
// promising something that will not happen.
|
||||||
|
Configured bool `json:"configured"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetMailAddress returns the personal address that turns an email into a
|
// handleGetMailAddress returns the personal address that turns an email into a
|
||||||
@@ -31,23 +37,29 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "mail token", err)
|
s.internal(w, r, "mail token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||||
|
Address: s.mailAddress(token),
|
||||||
|
Configured: s.cfg.SMTP.Host != "",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||||
token, err := auth.NewToken()
|
token, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate mail token", err)
|
s.internal(w, r, "generate mail token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)})
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||||
|
Address: s.mailAddress(token),
|
||||||
|
Configured: s.cfg.SMTP.Host != "",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mailAddress(token string) string {
|
func (s *Server) mailAddress(token string) string {
|
||||||
@@ -55,7 +67,15 @@ func (s *Server) mailAddress(token string) string {
|
|||||||
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
|
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
|
||||||
// The mail server's own domain, which is the one that can actually route
|
// The mail server's own domain, which is the one that can actually route
|
||||||
// this — the web address may well be a tunnel hostname that receives no mail.
|
// this — the web address may well be a tunnel hostname that receives no mail.
|
||||||
domain = from[strings.Index(from, "@")+1:]
|
//
|
||||||
|
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
|
||||||
|
// verdande@localhost, so an instance with no mail server at all was
|
||||||
|
// handing out todo+…@localhost: an address that looks real, cannot
|
||||||
|
// possibly receive anything, and gives no hint which of the two it is.
|
||||||
|
// The public hostname is at least a domain that exists.
|
||||||
|
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
|
||||||
|
domain = d
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "todo+" + token + "@" + domain
|
return "todo+" + token + "@" + domain
|
||||||
}
|
}
|
||||||
@@ -114,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
s.internal(w, "inbox", err)
|
s.internal(w, r, "inbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
|||||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +187,7 @@ type aiSettings struct {
|
|||||||
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The key is never sent back. A settings page that repopulates a password
|
// The key is never sent back. A settings page that repopulates a password
|
||||||
@@ -196,7 +216,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
existing, err := s.aiConfig(r, user.ID)
|
existing, err := s.aiConfig(r, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
||||||
@@ -211,7 +231,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"model": req.Model, "api_key": key,
|
"model": req.Model, "api_key": key,
|
||||||
}
|
}
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -246,19 +266,19 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
cfg, err := s.aiConfig(r, user.ID)
|
cfg, err := s.aiConfig(r, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !cfg.Configured() {
|
if !cfg.Configured() {
|
||||||
// Not an error the user did anything to cause: the feature is simply off.
|
// Not an error the user did anything to cause: the feature is simply off.
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
"no AI provider is configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
Priority: task.Priority, CreatedBy: user.ID,
|
Priority: task.Priority, CreatedBy: user.ID,
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
created = append(created, toTaskJSON(*child))
|
created = append(created, toTaskJSON(*child))
|
||||||
@@ -295,18 +315,18 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
cfg, err := s.aiConfig(r, user.ID)
|
cfg, err := s.aiConfig(r, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "ai settings", err)
|
s.internal(w, r, "ai settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !cfg.Configured() {
|
if !cfg.Configured() {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
||||||
"der er ikke sat en AI-udbyder op under indstillinger")
|
"no AI provider is configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list tasks", err)
|
s.internal(w, r, "list tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(tasks) == 0 {
|
if len(tasks) == 0 {
|
||||||
@@ -352,16 +372,34 @@ type gmailSettings struct {
|
|||||||
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
||||||
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
str := func(key string) string {
|
str := func(key string) string {
|
||||||
v, _ := values[key].(string)
|
v, _ := values[key].(string)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
|
// rather than until somebody reconnects.
|
||||||
|
email := str("email")
|
||||||
|
if email == "" && str("refresh_token") != "" {
|
||||||
|
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
|
||||||
|
email = fetched
|
||||||
|
} else {
|
||||||
|
s.log.Warn("gmail profile", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, gmailSettings{
|
writeJSON(w, http.StatusOK, gmailSettings{
|
||||||
Connected: str("refresh_token") != "",
|
Connected: str("refresh_token") != "",
|
||||||
Email: str("email"),
|
Email: email,
|
||||||
Trigger: str("trigger"),
|
Trigger: str("trigger"),
|
||||||
Label: str("label"),
|
Label: str("label"),
|
||||||
})
|
})
|
||||||
@@ -376,14 +414,14 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "gmail settings", err)
|
s.internal(w, r, "gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
values["trigger"] = req.Trigger
|
values["trigger"] = req.Trigger
|
||||||
values["label"] = req.Label
|
values["label"] = req.Label
|
||||||
|
|
||||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
||||||
s.internal(w, "save gmail settings", err)
|
s.internal(w, r, "save gmail settings", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -392,7 +430,7 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleDisconnectGmail forgets the connection, tokens and all.
|
// handleDisconnectGmail forgets the connection, tokens and all.
|
||||||
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
|
||||||
s.internal(w, "disconnect gmail", err)
|
s.internal(w, r, "disconnect gmail", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -771,3 +771,548 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
|
|||||||
t.Error("the current version is hidden from members")
|
t.Error("the current version is hidden from members")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MCP with the key in the URL --------------------------------------------------
|
||||||
|
|
||||||
|
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
|
||||||
|
// token — so this is the only shape that can be configured from it.
|
||||||
|
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := ts.apiToken(t, userID)
|
||||||
|
|
||||||
|
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||||
|
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||||
|
t.Fatalf("the response was not JSON-RPC: %v", err)
|
||||||
|
}
|
||||||
|
result, _ := decoded["result"].(map[string]any)
|
||||||
|
if result == nil || result["protocolVersion"] == nil {
|
||||||
|
t.Errorf("no protocol version was announced: %v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
|
||||||
|
// SPA fallback and answered 200 with the app shell. A connector pointed at it
|
||||||
|
// reported success and then failed to parse a page of HTML.
|
||||||
|
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
|
||||||
|
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A revoked key stops working, like any other.
|
||||||
|
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := ts.apiToken(t, userID)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
|
||||||
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session cookie must NOT work here. This route sits outside the CSRF check,
|
||||||
|
// so a cookie-authenticated POST would be a cross-site request that acts as
|
||||||
|
// whoever is signed in — the exact hole the key-only rule exists to close.
|
||||||
|
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// ts.client holds the session from bootstrap. No key in the URL.
|
||||||
|
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
|
||||||
|
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mail to task ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The address used to come out as todo+…@localhost on any instance without a
|
||||||
|
// mail server, because VERDANDE_SMTP_FROM defaults to verdande@localhost and the
|
||||||
|
// domain was taken from it unconditionally. That address looks real, cannot
|
||||||
|
// receive anything, and gives no hint which of the two it is.
|
||||||
|
func TestMailAddressDoesNotClaimToBeLocalhost(t *testing.T) {
|
||||||
|
ts := newTestServerWith(t, func(c *config.Config) {
|
||||||
|
c.BaseURL = "https://todo.example.dk"
|
||||||
|
// Left at the default, as an instance with no mail server has it.
|
||||||
|
c.SMTP.From = "verdande@localhost"
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||||
|
address, _ := body["address"].(string)
|
||||||
|
|
||||||
|
if strings.HasSuffix(address, "@localhost") {
|
||||||
|
t.Errorf("address = %q — that can never receive mail", address)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(address, "@todo.example.dk") {
|
||||||
|
t.Errorf("address = %q, want the public hostname", address)
|
||||||
|
}
|
||||||
|
if body["configured"] != false {
|
||||||
|
t.Error("no SMTP host is set, so configured should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real sender address still wins: the mail server's own domain is the one that
|
||||||
|
// can actually route this, and the web address may be a tunnel hostname.
|
||||||
|
func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
|
||||||
|
ts := newTestServerWith(t, func(c *config.Config) {
|
||||||
|
c.BaseURL = "https://tunnel-abc123.example.net"
|
||||||
|
c.SMTP.Host = "mail.firma.dk"
|
||||||
|
c.SMTP.From = "verdande@firma.dk"
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||||
|
address, _ := body["address"].(string)
|
||||||
|
|
||||||
|
if !strings.HasSuffix(address, "@firma.dk") {
|
||||||
|
t.Errorf("address = %q, want the sender's domain", address)
|
||||||
|
}
|
||||||
|
if body["configured"] != true {
|
||||||
|
t.Error("an SMTP host is set, so configured should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 200 with the app shell is an answer that says "yes, that exists" to anything
|
||||||
|
// that asks. For /.well-known/ that is actively harmful: it is a reserved
|
||||||
|
// namespace, and a client probing it is asking a yes-or-no question.
|
||||||
|
//
|
||||||
|
// Claude asks /.well-known/oauth-authorization-server before connecting to an
|
||||||
|
// MCP server. It read the shell as "there is an authorization server here" and
|
||||||
|
// then failed trying to register with it, reporting a broken sign-in service on
|
||||||
|
// a server that has none and needs none.
|
||||||
|
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/.well-known/oauth-authorization-server",
|
||||||
|
"/.well-known/oauth-protected-resource",
|
||||||
|
"/.well-known/openid-configuration",
|
||||||
|
"/.well-known/anything-at-all",
|
||||||
|
} {
|
||||||
|
resp, err := ts.client.Get(ts.URL + path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), "<!doctype html") {
|
||||||
|
t.Errorf("%s: answered with the app shell", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one well-known path that is real still works — it is a route, not a
|
||||||
|
// fallthrough, so the rule above must not have swallowed it.
|
||||||
|
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
resp, err := client.Get(ts.URL + "/.well-known/caldav")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
t.Error("CalDAV discovery was caught by the well-known rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- what the API accepts, it must actually do -------------------------------------
|
||||||
|
|
||||||
|
// project_id on an update used to be validated and then dropped: the destination
|
||||||
|
// was permission-checked, which reads as "understood", and the task never moved
|
||||||
|
// while the answer said OK. Both the REST path and the MCP tool did it.
|
||||||
|
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
if task["project_id"] == projectID {
|
||||||
|
t.Fatal("the task started in the destination; the test proves nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"project_id": projectID})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
|
||||||
|
}
|
||||||
|
if updated["project_id"] != projectID {
|
||||||
|
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
|
||||||
|
updated["project_id"], projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it really is there, not merely reported as there.
|
||||||
|
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if reread["project_id"] != projectID {
|
||||||
|
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
|
||||||
|
taskID := created["id"].(string)
|
||||||
|
|
||||||
|
moved := ts.callTool(t, "update_task", map[string]any{
|
||||||
|
"task_id": taskID, "project_id": projectID,
|
||||||
|
})
|
||||||
|
if moved["project_id"] != projectID {
|
||||||
|
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project you cannot write to is refused rather than quietly ignored.
|
||||||
|
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
mine := task["project_id"]
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"project_id": theirs["id"]})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if reread["project_id"] != mine {
|
||||||
|
t.Error("the task moved into somebody else's project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown #project is still not an error — the thought is kept rather than
|
||||||
|
// thrown away over a typo — but it is no longer silent. It used to vanish from
|
||||||
|
// the title while the task went to the Inbox, with nothing saying why.
|
||||||
|
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["unknown_project"] != "3Dekoration" {
|
||||||
|
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
|
||||||
|
}
|
||||||
|
if body["content"] != "Lav plakat" {
|
||||||
|
t.Errorf("content = %v", body["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "Lav plakat #Dekoration"})
|
||||||
|
if _, present := body["unknown_project"]; present {
|
||||||
|
t.Errorf("reported an unknown project for one that exists: %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||||
|
note, _ := created["note"].(string)
|
||||||
|
if !strings.Contains(note, "3Dekoration") {
|
||||||
|
t.Errorf("note = %q, want it to name the project that does not exist", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
|
||||||
|
// with seven children arrived as eight unrelated tasks.
|
||||||
|
func TestMCPCreateMakesSubtasks(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
parent := ts.callTool(t, "create_task", map[string]any{
|
||||||
|
"content": "Runen v2", "project_id": project["id"],
|
||||||
|
})
|
||||||
|
parentID := parent["id"].(string)
|
||||||
|
|
||||||
|
child := ts.callTool(t, "create_task", map[string]any{
|
||||||
|
"content": "Tegn ikonet", "parent_id": parentID,
|
||||||
|
})
|
||||||
|
if child["parent_id"] != parentID {
|
||||||
|
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
|
||||||
|
}
|
||||||
|
// A sub-task joins its parent's project whatever else was asked for.
|
||||||
|
if child["project_id"] != project["id"] {
|
||||||
|
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
|
||||||
|
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
|
||||||
|
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
|
||||||
|
child := ts.callTool(t, "create_task", map[string]any{
|
||||||
|
"content": "Tegn ikonet", "parent_id": parent["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// A tool that refuses reports it inside the result with isError, not as a
|
||||||
|
// JSON-RPC error — the call itself was well formed, the arguments were not.
|
||||||
|
response := ts.rpc(t, "tools/call", map[string]any{
|
||||||
|
"name": "create_task",
|
||||||
|
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
|
||||||
|
})
|
||||||
|
result, _ := response["result"].(map[string]any)
|
||||||
|
if result == nil || result["isError"] != true {
|
||||||
|
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Gmail OAuth client, enterable rather than deployed.
|
||||||
|
//
|
||||||
|
// The registration itself cannot be avoided — Google issues no Gmail access to an
|
||||||
|
// unregistered client — but having to edit a Rune's manifest and recreate the
|
||||||
|
// container to paste the result can be, and that is what this covers.
|
||||||
|
func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, empty := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||||
|
if empty["client_id"] != "" || empty["has_secret"] != false {
|
||||||
|
t.Errorf("a fresh instance already has a client: %v", empty)
|
||||||
|
}
|
||||||
|
if empty["from_env"] != false {
|
||||||
|
t.Errorf("from_env = %v with nothing in the environment", empty["from_env"])
|
||||||
|
}
|
||||||
|
// The single most likely thing to be wrong, so it is spelled out rather than
|
||||||
|
// left for somebody to reconstruct.
|
||||||
|
if empty["redirect_uri"] != "http://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, "anden@example.dk", "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(), "kristian@example.dk")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/mcp"
|
"github.com/kristianwind/verdande/internal/mcp"
|
||||||
"github.com/kristianwind/verdande/internal/quickadd"
|
"github.com/kristianwind/verdande/internal/quickadd"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
@@ -55,7 +58,8 @@ func (s *Server) buildMCP() *mcp.Server {
|
|||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||||
"content": mcp.Str("The task title, if not using `text`."),
|
"content": mcp.Str("The task title, if not using `text`."),
|
||||||
"project_id": mcp.Str("Which project. Defaults to the Inbox."),
|
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||||
|
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||||
"description": mcp.Str("Longer notes on the task."),
|
"description": mcp.Str("Longer notes on the task."),
|
||||||
@@ -124,6 +128,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, response)
|
writeJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleMCPWithKey is the same endpoint for a client that can only be given a
|
||||||
|
// URL: `POST /mcp?key=vrd_…`.
|
||||||
|
//
|
||||||
|
// Claude's custom-connector dialog asks for a name, an address and — under
|
||||||
|
// advanced settings — an OAuth client id and secret. There is no field for a
|
||||||
|
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
|
||||||
|
// address is. The calendar feed already carries its credential in the URL for
|
||||||
|
// the same reason, and the request logger records the path without the query, so
|
||||||
|
// the key does not end up in the log.
|
||||||
|
//
|
||||||
|
// The key is the *only* thing accepted here. Reading a session cookie instead
|
||||||
|
// would make this a state-changing POST outside the CSRF check — which is to say
|
||||||
|
// a cross-site request that acts as whoever is signed in.
|
||||||
|
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" || !auth.IsAPIToken(key) {
|
||||||
|
// 401 rather than 404: unlike a task, the existence of this endpoint is
|
||||||
|
// not a secret — it is written in the documentation.
|
||||||
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||||
|
"add ?key=<api token> to the URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
s.internal(w, r, "mcp key", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
|
||||||
|
}
|
||||||
|
|
||||||
// --- tool implementations ------------------------------------------------------------
|
// --- tool implementations ------------------------------------------------------------
|
||||||
|
|
||||||
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||||
@@ -215,6 +255,7 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
|
ParentID string `json:"parent_id"`
|
||||||
DueDate string `json:"due_date"`
|
DueDate string `json:"due_date"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -230,6 +271,11 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
task := &store.Task{CreatedBy: userID, Priority: 4}
|
task := &store.Task{CreatedBy: userID, Priority: 4}
|
||||||
labels := params.Labels
|
labels := params.Labels
|
||||||
|
|
||||||
|
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
|
||||||
|
// title while the task went to the Inbox, with nothing anywhere saying so —
|
||||||
|
// invisible in a chat, where nobody sees the row that was written.
|
||||||
|
unknownProject := ""
|
||||||
|
|
||||||
if text := strings.TrimSpace(params.Text); text != "" {
|
if text := strings.TrimSpace(params.Text); text != "" {
|
||||||
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||||
task.Content = parsed.Content
|
task.Content = parsed.Content
|
||||||
@@ -242,6 +288,8 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
if parsed.Project != "" && params.ProjectID == "" {
|
if parsed.Project != "" && params.ProjectID == "" {
|
||||||
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
||||||
task.ProjectID = id
|
task.ProjectID = id
|
||||||
|
} else {
|
||||||
|
unknownProject = parsed.Project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +315,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
if params.ProjectID != "" {
|
if params.ProjectID != "" {
|
||||||
task.ProjectID = params.ProjectID
|
task.ProjectID = params.ProjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A sub-task belongs to its parent's project, whatever else was asked for:
|
||||||
|
// the two cannot disagree, and the parent is the more specific instruction.
|
||||||
|
// Without this the tools could not build a tree at all — an import of a
|
||||||
|
// parent with seven children arrived as eight unrelated tasks.
|
||||||
|
if params.ParentID != "" {
|
||||||
|
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
|
||||||
|
}
|
||||||
|
if parent.ParentID != "" {
|
||||||
|
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
|
||||||
|
}
|
||||||
|
task.ParentID = parent.ID
|
||||||
|
task.ProjectID = parent.ProjectID
|
||||||
|
}
|
||||||
|
|
||||||
if task.ProjectID == "" {
|
if task.ProjectID == "" {
|
||||||
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -281,10 +346,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
||||||
return map[string]any{
|
|
||||||
|
out := map[string]any{
|
||||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||||
"due_date": task.DueDate, "project_id": task.ProjectID,
|
"due_date": task.DueDate, "project_id": task.ProjectID,
|
||||||
}, nil
|
}
|
||||||
|
if task.ParentID != "" {
|
||||||
|
out["parent_id"] = task.ParentID
|
||||||
|
}
|
||||||
|
if unknownProject != "" {
|
||||||
|
// Phrased for a model to relay: it says what happened and what to do,
|
||||||
|
// so the person is told rather than left with a task in the wrong place.
|
||||||
|
out["note"] = fmt.Sprintf(
|
||||||
|
"There is no project called %q, so this went to the Inbox. "+
|
||||||
|
"Create the project and move it, or say which project you meant.",
|
||||||
|
unknownProject)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||||
@@ -326,8 +404,11 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
if params.Labels != nil {
|
if params.Labels != nil {
|
||||||
update.SetLabels, update.Labels = true, *params.Labels
|
update.SetLabels, update.Labels = true, *params.Labels
|
||||||
}
|
}
|
||||||
|
// Moved before the field update, and through the same helper the REST handler
|
||||||
|
// uses. This used to check the caller's rights on the destination and then
|
||||||
|
// drop the argument, so the task never moved and the answer still said OK.
|
||||||
if params.ProjectID != nil {
|
if params.ProjectID != nil {
|
||||||
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
|
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
|
||||||
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,9 +422,13 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
}
|
}
|
||||||
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
||||||
|
|
||||||
|
// project_id is in the answer because it is one of the things that can be
|
||||||
|
// changed here: a caller that asked for a move can see whether it happened
|
||||||
|
// rather than taking "OK" on trust.
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||||
"due_date": task.DueDate, "labels": task.Labels,
|
"due_date": task.DueDate, "labels": task.Labels,
|
||||||
|
"project_id": task.ProjectID,
|
||||||
}, nil
|
}, 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
|
// 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.
|
// and shows the sign-in screen — and nothing in the logs says why.
|
||||||
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
||||||
s.internal(w, "authenticate", err)
|
s.internal(w, r, "authenticate", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list notifications", err)
|
s.internal(w, r, "list notifications", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "count notifications", err)
|
s.internal(w, r, "count notifications", err)
|
||||||
return
|
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) {
|
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "mark notifications read", err)
|
s.internal(w, r, "mark notifications read", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -148,7 +148,7 @@ type pushSubscriptionRequest struct {
|
|||||||
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||||
public, _, err := s.vapidKeys(r.Context())
|
public, _, err := s.vapidKeys(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "vapid keys", err)
|
s.internal(w, r, "vapid keys", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
||||||
@@ -172,7 +172,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "save push subscription", err)
|
s.internal(w, r, "save push subscription", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"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()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "build panel request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
|
||||||
|
|
||||||
|
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, http.StatusBadGateway, 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,131 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restarting is administrators only, and sessions only. A leaked API token that
|
||||||
|
// could restart the server is a denial of service in one request.
|
||||||
|
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
|
||||||
|
method := "GET"
|
||||||
|
if path != "/api/v1/panel" {
|
||||||
|
method = "POST"
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, method, 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(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, admin.ID)
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", 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("restart with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconfigured, it says which of the three settings are missing rather than
|
||||||
|
// "not configured" — an operator should not have to go and find out which.
|
||||||
|
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||||
|
if status["configured"] != false {
|
||||||
|
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
|
||||||
|
}
|
||||||
|
missing, _ := status["missing"].([]any)
|
||||||
|
if len(missing) != 3 {
|
||||||
|
t.Errorf("missing = %v, want all three settings named", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["error"] == nil {
|
||||||
|
t.Error("the refusal says nothing about why")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured, it calls the panel with a bearer token and reports what came back.
|
||||||
|
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
|
||||||
|
var gotPath, gotAuth string
|
||||||
|
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer panel.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.PanelURL = panel.URL
|
||||||
|
cfg.PanelToken = "hemmelig"
|
||||||
|
cfg.PanelServerID = "abc-123"
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||||
|
if status["configured"] != true {
|
||||||
|
t.Fatalf("configured = %v with all three set", status["configured"])
|
||||||
|
}
|
||||||
|
// The token is a credential and must never reach a browser.
|
||||||
|
for key, value := range status {
|
||||||
|
if s, ok := value.(string); ok && s == "hemmelig" {
|
||||||
|
t.Errorf("the panel token was sent to the client in %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
|
if resp.StatusCode != http.StatusAccepted {
|
||||||
|
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/servers/abc-123/restart" {
|
||||||
|
t.Errorf("called %q", gotPath)
|
||||||
|
}
|
||||||
|
if gotAuth != "Bearer hemmelig" {
|
||||||
|
t.Errorf("Authorization = %q", gotAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A panel that refuses is reported as the panel refusing, not as this server
|
||||||
|
// breaking: the fix is a token with control of that server, and saying "internal
|
||||||
|
// error" would send somebody looking in the wrong place.
|
||||||
|
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
|
||||||
|
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
defer panel.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.PanelURL = panel.URL
|
||||||
|
cfg.PanelToken = "forkert"
|
||||||
|
cfg.PanelServerID = "abc-123"
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
|
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if msg, _ := body["error"].(string); msg == "" {
|
||||||
|
t.Error("the refusal does not say what to check")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(), "kristian@example.dk")
|
||||||
|
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"] != "kristian@example.dk" {
|
||||||
|
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, "anden@example.dk", "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, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Groups are the foldable headings over the projects in the sidebar.
|
||||||
|
//
|
||||||
|
// They sit outside /projects rather than under it: a group is not a project and
|
||||||
|
// has no members, no tasks and no roles. Every route here is scoped to the caller
|
||||||
|
// in the query itself, so there is no permission middleware to forget — a group
|
||||||
|
// that is not yours is not found.
|
||||||
|
|
||||||
|
type projectGroupJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Collapsed bool `json:"collapsed"`
|
||||||
|
SortOrder float64 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||||
|
return projectGroupJSON{
|
||||||
|
ID: g.ID, Name: g.Name, Color: g.Color, Description: g.Description,
|
||||||
|
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list project groups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]projectGroupJSON, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
out = append(out, toProjectGroupJSON(g))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectGroupRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Collapsed *bool `json:"collapsed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req projectGroupRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if req.Name != nil {
|
||||||
|
name = strings.TrimSpace(*req.Name)
|
||||||
|
}
|
||||||
|
if fields := validateGroupName(name); len(fields) > 0 {
|
||||||
|
writeFieldErrors(w, fields)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Color != nil && !store.ValidColor(*req.Color) {
|
||||||
|
writeFieldErrors(w, map[string]string{"color": colorFieldError})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
|
||||||
|
if req.Color != nil {
|
||||||
|
g.Color = *req.Color
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
g.Description = strings.TrimSpace(*req.Description)
|
||||||
|
}
|
||||||
|
if req.Collapsed != nil {
|
||||||
|
g.Collapsed = *req.Collapsed
|
||||||
|
}
|
||||||
|
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
||||||
|
s.internal(w, r, "create project group", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||||||
|
writeJSON(w, http.StatusCreated, toProjectGroupJSON(*g))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req projectGroupRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID := chi.URLParam(r, "groupID")
|
||||||
|
userID := userFrom(r.Context()).ID
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
name := strings.TrimSpace(*req.Name)
|
||||||
|
if fields := validateGroupName(name); len(fields) > 0 {
|
||||||
|
writeFieldErrors(w, fields)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = &name
|
||||||
|
}
|
||||||
|
if req.Color != nil && !store.ValidColor(*req.Color) {
|
||||||
|
writeFieldErrors(w, map[string]string{"color": colorFieldError})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Description != nil {
|
||||||
|
description := strings.TrimSpace(*req.Description)
|
||||||
|
if len(description) > 4000 {
|
||||||
|
writeFieldErrors(w, map[string]string{"description": "must be at most 4000 characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Description = &description
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
|
||||||
|
Name: req.Name, Color: req.Color, Description: req.Description,
|
||||||
|
Collapsed: req.Collapsed,
|
||||||
|
}); err != nil {
|
||||||
|
s.storeError(w, r, "update project group", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get project group", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
||||||
|
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupProjectJSON is a project as the group's page lists it: the project, plus
|
||||||
|
// how much is left in it. Open tasks, not all of them — "12" beside a project
|
||||||
|
// somebody finished last year is a number that means nothing.
|
||||||
|
type groupProjectJSON struct {
|
||||||
|
projectJSON
|
||||||
|
OpenTasks int `json:"open_tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetProjectGroup is the group as a place rather than a heading.
|
||||||
|
//
|
||||||
|
// A heading can carry a name. A page carries what the group *is* — the sentence
|
||||||
|
// that says why these projects are one body of work, the documents that belong to
|
||||||
|
// all of them rather than to any one, and the projects themselves with what is
|
||||||
|
// left in each.
|
||||||
|
//
|
||||||
|
// The projects come with the group rather than being fetched per row, because the
|
||||||
|
// page is nothing without them and two round trips to draw one list is one too
|
||||||
|
// many.
|
||||||
|
func (s *Server) handleGetProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID := chi.URLParam(r, "groupID")
|
||||||
|
userID := userFrom(r.Context()).ID
|
||||||
|
|
||||||
|
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get project group", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := s.db.ListProjects(r.Context(), userID, false)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts, err := s.db.OpenTaskCounts(r.Context(), groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "open task counts", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inside := make([]groupProjectJSON, 0, 4)
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.GroupID == groupID {
|
||||||
|
inside = append(inside, groupProjectJSON{
|
||||||
|
projectJSON: toProjectJSON(p), OpenTasks: counts[p.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := s.db.ListGroupAttachments(r.Context(), groupID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list group attachments", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attachments := make([]attachmentJSON, 0, len(files))
|
||||||
|
for _, a := range files {
|
||||||
|
attachments = append(attachments, toAttachmentJSON(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"group": toProjectGroupJSON(*g), "projects": inside, "attachments": attachments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUploadGroupAttachment puts a document on the group itself.
|
||||||
|
//
|
||||||
|
// The same storage as everywhere else — content-addressed by hash, so the path
|
||||||
|
// never contains anything the uploader chose. What differs is only the parent.
|
||||||
|
func (s *Server) handleUploadGroupAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID := chi.URLParam(r, "groupID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
// Scoped by the lookup rather than by a separate check: a group that is not
|
||||||
|
// yours is not found, which is the rule for everything in this file.
|
||||||
|
if _, err := s.db.GetProjectGroup(r.Context(), groupID, user.ID); err != nil {
|
||||||
|
s.storeError(w, r, "get project group", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||||
|
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||||
|
"that file is too large (25 MB maximum)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if header.Size > maxUploadBytes {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||||
|
"that file is too large (25 MB maximum)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, size, err := s.storeUpload(file)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "store upload", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &store.Attachment{
|
||||||
|
GroupID: groupID,
|
||||||
|
Filename: safeFilename(header.Filename),
|
||||||
|
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||||
|
Size: size,
|
||||||
|
Path: stored,
|
||||||
|
UploadedBy: user.ID,
|
||||||
|
}
|
||||||
|
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||||
|
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||||
|
s.internal(w, r, "record attachment", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteProjectGroup removes the heading. The projects under it come back
|
||||||
|
// out as ungrouped, which is why this is not behind a confirmation about losing
|
||||||
|
// work: there is none to lose.
|
||||||
|
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID := chi.URLParam(r, "groupID")
|
||||||
|
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
||||||
|
s.storeError(w, r, "delete project group", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReorderProjectGroups takes the whole list, like the projects do — see
|
||||||
|
// handleReorderProjects for why.
|
||||||
|
func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reorderProjectsRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) > 500 {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||||
|
s.internal(w, r, "reorder project groups", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGroupName(name string) map[string]string {
|
||||||
|
switch {
|
||||||
|
case name == "":
|
||||||
|
return map[string]string{"name": "required"}
|
||||||
|
case len([]rune(name)) > 200:
|
||||||
|
return map[string]string{"name": "must be 200 characters or fewer"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAGroupHoldsProjectsAndSurvivesBeingEmptied(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create group: %d %v", resp.StatusCode, group)
|
||||||
|
}
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
if group["collapsed"] != false {
|
||||||
|
t.Errorf("a new group should be open: %v", group)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, filed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID,
|
||||||
|
map[string]any{"group_id": groupID})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("file project: %d %v", resp.StatusCode, filed)
|
||||||
|
}
|
||||||
|
if filed["group_id"] != groupID {
|
||||||
|
t.Errorf("group_id = %v, want %s", filed["group_id"], groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listing is what the sidebar reads, so the filing has to survive it.
|
||||||
|
if got := groupOf(t, ts, projectID); got != groupID {
|
||||||
|
t.Errorf("group_id in the list = %q, want %s", got, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty group is still a group. This is the whole reason it is a table and
|
||||||
|
// not a string repeated on every project row.
|
||||||
|
resp, _ = ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": ""})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ungroup: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := groupOf(t, ts, projectID); got != "" {
|
||||||
|
t.Errorf("group_id = %q after ungrouping, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
if groups, _ := listed["groups"].([]any); len(groups) != 1 {
|
||||||
|
t.Fatalf("the emptied group is gone: %v", listed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An omitted group_id must not clear the field: that is the difference between a
|
||||||
|
// PATCH and a PUT, and renaming a project would otherwise tip it out of its group.
|
||||||
|
func TestRenamingAProjectLeavesItInItsGroup(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||||
|
|
||||||
|
_, renamed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"name": "Bogholderi"})
|
||||||
|
if renamed["group_id"] != groupID {
|
||||||
|
t.Errorf("group_id = %v after a rename, want %s", renamed["group_id"], groupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the heading is not a request to delete the work filed under it.
|
||||||
|
func TestDeletingAGroupKeepsItsProjects(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete group: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
found := false
|
||||||
|
for _, raw := range projects["projects"].([]any) {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if p["id"] != projectID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if p["group_id"] != nil && p["group_id"] != "" {
|
||||||
|
t.Errorf("the project still points at a group that is gone: %v", p["group_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the project went with the group it was filed under")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folding is stored on the account rather than in the browser, so it has to come
|
||||||
|
// back from the server.
|
||||||
|
func TestFoldingAGroupIsRemembered(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
|
||||||
|
resp, folded := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||||
|
map[string]any{"collapsed": true})
|
||||||
|
if resp.StatusCode != http.StatusOK || folded["collapsed"] != true {
|
||||||
|
t.Fatalf("fold: %d %v", resp.StatusCode, folded)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
first := listed["groups"].([]any)[0].(map[string]any)
|
||||||
|
if first["collapsed"] != true {
|
||||||
|
t.Errorf("collapsed = %v after re-reading, want true", first["collapsed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups are per person: somebody else's is not yours to see, rename or delete,
|
||||||
|
// and a group that is not yours is not found rather than forbidden.
|
||||||
|
func TestGroupsArePerPerson(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, listed := other.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
if groups, _ := listed["groups"].([]any); len(groups) != 0 {
|
||||||
|
t.Errorf("somebody else's groups are visible: %v", groups)
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||||
|
map[string]any{"name": "Mit"}); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("renaming somebody else's group: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil); resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("deleting somebody else's group: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project cannot be filed into a group that is not the caller's, even though
|
||||||
|
// both the project and the group exist and both requests would be well-formed.
|
||||||
|
func TestAProjectCannotJoinSomebodyElsesGroup(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
_, group := other.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Deres"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404 — a group you cannot see is not one you can file into",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsReorderAsAWholeList(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ids := make([]string, 3)
|
||||||
|
for i, name := range []string{"En", "To", "Tre"} {
|
||||||
|
_, g := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": name})
|
||||||
|
ids[i] = g["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
reversed := []string{ids[2], ids[1], ids[0]}
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/project-groups/reorder", map[string]any{"ids": reversed})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||||
|
got := listed["groups"].([]any)
|
||||||
|
for i, want := range reversed {
|
||||||
|
if got[i].(map[string]any)["id"] != want {
|
||||||
|
t.Fatalf("order = %v, want %v", got, reversed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A name is required, the same as a project's. An unnamed heading is a heading
|
||||||
|
// nobody can tell apart from the next one.
|
||||||
|
func TestAGroupNeedsAName(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": " "})
|
||||||
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("status %d, want 422 — body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupOf reads a project's group back out of the listing the sidebar uses.
|
||||||
|
func groupOf(t *testing.T, ts *testServer, projectID string) string {
|
||||||
|
t.Helper()
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
for _, raw := range body["projects"].([]any) {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if p["id"] == projectID {
|
||||||
|
id, _ := p["group_id"].(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("project %s is not in the listing", projectID)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colour is a name from a closed set, on both a project and a group.
|
||||||
|
//
|
||||||
|
// Refused rather than stored: an unknown name would be written happily and then
|
||||||
|
// painted as the default, which looks like a colour that did not save.
|
||||||
|
func TestAColourMustBeOneFromThePalette(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
if group["color"] != "graphite" {
|
||||||
|
t.Errorf("a new group's colour = %v, want graphite", group["color"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
what, method, path string
|
||||||
|
}{
|
||||||
|
{"group", "PATCH", "/api/v1/project-groups/" + groupID},
|
||||||
|
{"project", "PATCH", "/api/v1/projects/" + projectID},
|
||||||
|
} {
|
||||||
|
resp, body := ts.do(t, c.method, c.path, map[string]any{"color": "teal"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("%s, a real colour: %d %v", c.what, resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["color"] != "teal" {
|
||||||
|
t.Errorf("%s colour = %v, want teal", c.what, body["color"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body = ts.do(t, c.method, c.path, map[string]any{"color": "#ff0000"})
|
||||||
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||||
|
t.Errorf("%s, a hex value: status %d, want 422 — body %v", c.what, resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rename does not disturb the colour, and a recolour does not disturb the
|
||||||
|
// name: both are PATCH fields on the same row, and the whole point of a PATCH
|
||||||
|
// is that the one you did not send is the one you did not mean.
|
||||||
|
_, renamed := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||||
|
map[string]any{"name": "Kontoret"})
|
||||||
|
if renamed["color"] != "teal" {
|
||||||
|
t.Errorf("colour = %v after a rename, want teal", renamed["color"])
|
||||||
|
}
|
||||||
|
if renamed["name"] != "Kontoret" {
|
||||||
|
t.Errorf("name = %v", renamed["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group is somewhere you can go, not only a heading.
|
||||||
|
func TestAGroupIsAPageWithItsProjectsDescriptionAndFiles(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||||
|
groupID := group["id"].(string)
|
||||||
|
|
||||||
|
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{
|
||||||
|
"name": "Sæsonstart", "group_id": groupID,
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab", "group_id": groupID})
|
||||||
|
// And one outside it, which must not appear.
|
||||||
|
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Haven"})
|
||||||
|
|
||||||
|
// Two open, one closed — the count is what is left, not what has ever been.
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "skriv oplæg", "project_id": first["id"].(string),
|
||||||
|
})
|
||||||
|
_, done := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "book lokale", "project_id": first["id"].(string),
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/"+done["id"].(string)+"/complete", nil)
|
||||||
|
|
||||||
|
resp, page := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("get group: status %d, body %v", resp.StatusCode, page)
|
||||||
|
}
|
||||||
|
projects, _ := page["projects"].([]any)
|
||||||
|
if len(projects) != 2 {
|
||||||
|
t.Fatalf("the page lists %d projects, want 2", len(projects))
|
||||||
|
}
|
||||||
|
for _, raw := range projects {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if p["name"] == "Haven" {
|
||||||
|
t.Error("a project outside the group is on its page")
|
||||||
|
}
|
||||||
|
if p["name"] == "Sæsonstart" && p["open_tasks"] != float64(1) {
|
||||||
|
t.Errorf("open_tasks = %v, want 1 — the closed one must not count", p["open_tasks"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The description, which is the part a heading cannot carry.
|
||||||
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID, map[string]any{
|
||||||
|
"description": "Alt det, der har en faktura for enden.",
|
||||||
|
}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("set description: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, again := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
||||||
|
if g, _ := again["group"].(map[string]any); g["description"] != "Alt det, der har en faktura for enden." {
|
||||||
|
t.Errorf("description = %v", g["description"])
|
||||||
|
}
|
||||||
|
// And setting only the description must not lose the name or colour.
|
||||||
|
if g, _ := again["group"].(map[string]any); g["name"] != "Arbejde" {
|
||||||
|
t.Errorf("the name went with the description: %v", g["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group belongs to one person and is never shared, so somebody else's group is
|
||||||
|
// not found — including its files.
|
||||||
|
func TestAGroupsPageAndFilesAreItsOwnersAlone(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ type projectJSON struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
MemberCount int `json:"member_count"`
|
MemberCount int `json:"member_count"`
|
||||||
Shared bool `json:"shared"`
|
Shared bool `json:"shared"`
|
||||||
|
// GroupID is the sidebar group the asking user has filed it under, empty for
|
||||||
|
// none. Always empty on a project shared with them: grouping is a column on
|
||||||
|
// the project row, so it is the owner's filing and nobody else's.
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toProjectJSON(p store.Project) projectJSON {
|
func toProjectJSON(p store.Project) projectJSON {
|
||||||
@@ -30,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
|
|||||||
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
||||||
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
|
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,
|
||||||
Shared: p.MemberCount > 1,
|
Shared: p.MemberCount > 1, GroupID: p.GroupID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list projects", err)
|
s.internal(w, r, "list projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]projectJSON, 0, len(projects))
|
out := make([]projectJSON, 0, len(projects))
|
||||||
@@ -54,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||||
@@ -66,6 +71,44 @@ type projectRequest struct {
|
|||||||
Icon *string `json:"icon"`
|
Icon *string `json:"icon"`
|
||||||
ViewMode *string `json:"view_mode"`
|
ViewMode *string `json:"view_mode"`
|
||||||
Archived *bool `json:"archived"`
|
Archived *bool `json:"archived"`
|
||||||
|
// GroupID files the project under a sidebar group; "" takes it out of one.
|
||||||
|
// The empty string rather than a JSON null, because null and absent are the
|
||||||
|
// same nil pointer once decoded — and telling "put it nowhere" apart from
|
||||||
|
// "I did not mention it" is the whole point of a PATCH.
|
||||||
|
GroupID *string `json:"group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reorderProjectsRequest struct {
|
||||||
|
// IDs in the order they should appear, top to bottom.
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReorderProjects sets the sidebar order.
|
||||||
|
//
|
||||||
|
// The whole list rather than one move: a person has a handful of projects, and
|
||||||
|
// sending the order you want is both simpler to reason about and impossible to
|
||||||
|
// get into the half-ordered state a sequence of individual moves can produce if
|
||||||
|
// one of them fails.
|
||||||
|
//
|
||||||
|
// Projects the caller does not own are skipped — sort_order is a column on the
|
||||||
|
// project, so a shared one sits where its owner put it.
|
||||||
|
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reorderProjectsRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A ceiling rather than a guess at what is reasonable: this is a bound on
|
||||||
|
// the transaction, not an opinion about how many projects somebody may have.
|
||||||
|
if len(req.IDs) > 500 {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||||
|
s.internal(w, r, "reorder projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -79,7 +122,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
name = strings.TrimSpace(*req.Name)
|
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)
|
writeFieldErrors(w, fields)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,10 +138,23 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
p.ViewMode = *req.ViewMode
|
p.ViewMode = *req.ViewMode
|
||||||
}
|
}
|
||||||
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
||||||
s.internal(w, "create project", err)
|
s.internal(w, r, "create project", err)
|
||||||
return
|
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.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))
|
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +170,13 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
name = strings.TrimSpace(*req.Name)
|
name = strings.TrimSpace(*req.Name)
|
||||||
req.Name = &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)
|
writeFieldErrors(w, fields)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,16 +186,28 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
ViewMode: req.ViewMode, Archived: req.Archived,
|
ViewMode: req.ViewMode, Archived: req.Archived,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "update project", err)
|
s.storeError(w, r, "update project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Grouping is written separately because it is scoped differently: the fields
|
||||||
|
// above belong to the project, and this one belongs to whoever is filing it.
|
||||||
|
// The route already requires ownership, so the check inside is a second lock
|
||||||
|
// on the same door rather than a different one.
|
||||||
|
if req.GroupID != nil {
|
||||||
|
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
||||||
|
userFrom(r.Context()).ID); err != nil {
|
||||||
|
s.storeError(w, r, "set project group", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
s.activity(r, projectID, "", "project.updated", nil)
|
s.activity(r, projectID, "", "project.updated", nil)
|
||||||
|
|
||||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,23 +215,56 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
|
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
|
||||||
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
|
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
|
||||||
writeError(w, http.StatusConflict, CodeConflict, "the Inbox cannot be deleted")
|
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.storeError(w, "delete project", err)
|
s.storeError(w, r, "delete project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "project.deleted", nil)
|
s.activity(r, projectID, "", "project.deleted", nil)
|
||||||
|
s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type trashedProjectJSON struct {
|
||||||
|
projectJSON
|
||||||
|
DeletedAt string `json:"deleted_at"`
|
||||||
|
// PurgeAfter is when it stops being recoverable. Sent rather than left for
|
||||||
|
// the frontend to work out, because the retention window is the server's and
|
||||||
|
// a copy of it in the interface is a copy that will one day disagree.
|
||||||
|
PurgeAfter string `json:"purge_after"`
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTrashedProjects is what makes restoring reachable. The restore
|
||||||
|
// endpoint has always existed; without this, using it meant knowing the id of
|
||||||
|
// something that had already vanished from the interface.
|
||||||
|
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list trashed projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]trashedProjectJSON, 0, len(trashed))
|
||||||
|
for _, t := range trashed {
|
||||||
|
out = append(out, trashedProjectJSON{
|
||||||
|
projectJSON: toProjectJSON(t.Project),
|
||||||
|
DeletedAt: t.DeletedAt.Format(time.RFC3339),
|
||||||
|
PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339),
|
||||||
|
TaskCount: t.TaskCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
// Permission cannot be checked through ProjectRole here: a trashed project is
|
// Permission cannot be checked through ProjectRole here: a trashed project is
|
||||||
// deliberately invisible to it. Ownership is what governs restoring.
|
// deliberately invisible to it. Ownership is what governs restoring.
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if owner != userFrom(r.Context()).ID {
|
if owner != userFrom(r.Context()).ID {
|
||||||
@@ -165,13 +272,18 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
||||||
s.storeError(w, "restore project", err)
|
s.storeError(w, r, "restore project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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{}
|
fields := map[string]string{}
|
||||||
switch {
|
switch {
|
||||||
case name == "":
|
case name == "":
|
||||||
@@ -186,6 +298,11 @@ func validateProject(name string, viewMode *string) map[string]string {
|
|||||||
fields["view_mode"] = "must be list, board or calendar"
|
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
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +318,7 @@ type sectionJSON struct {
|
|||||||
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
||||||
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list sections", err)
|
s.internal(w, r, "list sections", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]sectionJSON, 0, len(sections))
|
out := make([]sectionJSON, 0, len(sections))
|
||||||
@@ -231,7 +348,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
sec.SortOrder = *req.SortOrder
|
sec.SortOrder = *req.SortOrder
|
||||||
}
|
}
|
||||||
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
||||||
s.internal(w, "create section", err)
|
s.internal(w, r, "create section", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
||||||
@@ -245,7 +362,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
|
|||||||
sectionID := chi.URLParam(r, "sectionID")
|
sectionID := chi.URLParam(r, "sectionID")
|
||||||
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "section project", err)
|
s.storeError(w, r, "section project", err)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
||||||
@@ -276,7 +393,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
||||||
Name: req.Name, SortOrder: req.SortOrder,
|
Name: req.Name, SortOrder: req.SortOrder,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
s.storeError(w, "update section", err)
|
s.storeError(w, r, "update section", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "section.updated", nil)
|
s.activity(r, projectID, "", "section.updated", nil)
|
||||||
@@ -289,7 +406,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
||||||
s.storeError(w, "delete section", err)
|
s.storeError(w, r, "delete section", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "section.deleted", nil)
|
s.activity(r, projectID, "", "section.deleted", nil)
|
||||||
@@ -309,7 +426,7 @@ type memberJSON struct {
|
|||||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list members", err)
|
s.internal(w, r, "list members", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]memberJSON, 0, len(members))
|
out := make([]memberJSON, 0, len(members))
|
||||||
@@ -358,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get project", err)
|
s.storeError(w, r, "get project", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +487,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||||
@@ -380,7 +497,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create invite", err)
|
s.internal(w, r, "create invite", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||||
@@ -397,13 +514,60 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
|
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) {
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
userID := chi.URLParam(r, "userID")
|
userID := chi.URLParam(r, "userID")
|
||||||
|
|
||||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "project owner", err)
|
s.storeError(w, r, "project owner", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if userID == owner {
|
if userID == owner {
|
||||||
@@ -412,7 +576,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
||||||
@@ -424,13 +588,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
|
// 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
|
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
|
||||||
// to learn.
|
// 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 {
|
switch {
|
||||||
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
||||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
case errors.Is(err, store.ErrEmailInUse):
|
case errors.Is(err, store.ErrEmailInUse):
|
||||||
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
||||||
default:
|
default:
|
||||||
s.internal(w, what, err)
|
s.internal(w, r, what, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restoring has been possible since the trash existed. Finding what to restore
|
||||||
|
// has not: the endpoint takes an id, and the id is exactly what you no longer
|
||||||
|
// have once the project has gone from the interface.
|
||||||
|
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
|
||||||
|
id := created["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gone from the ordinary listing.
|
||||||
|
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
for _, raw := range live["projects"].([]any) {
|
||||||
|
if raw.(map[string]any)["id"] == id {
|
||||||
|
t.Fatal("a deleted project is still in the project list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And findable in the trash, with enough to decide whether to bring it back.
|
||||||
|
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
list, _ := trash["projects"].([]any)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("want one project in the trash, got %v", list)
|
||||||
|
}
|
||||||
|
entry := list[0].(map[string]any)
|
||||||
|
if entry["name"] != "Ferie" {
|
||||||
|
t.Errorf("name = %v", entry["name"])
|
||||||
|
}
|
||||||
|
if entry["task_count"] != float64(1) {
|
||||||
|
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
|
||||||
|
entry["task_count"])
|
||||||
|
}
|
||||||
|
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
|
||||||
|
t.Errorf("no deadline was reported: %v", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("restore: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back, with its task.
|
||||||
|
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
|
||||||
|
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
|
||||||
|
t.Errorf("the task did not come back with the project: %v", got)
|
||||||
|
}
|
||||||
|
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||||
|
t.Errorf("still in the trash after restoring: %v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's deleted project is not in your trash, and you cannot restore it.
|
||||||
|
func TestTheTrashIsPerOwner(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
id := created["id"].(string)
|
||||||
|
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||||
|
t.Errorf("somebody else's deleted project is visible: %v", list)
|
||||||
|
}
|
||||||
|
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
|
||||||
|
ids = append(ids, body["id"].(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to front.
|
||||||
|
reversed := []string{ids[2], ids[1], ids[0]}
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
projects, _ := list["projects"].([]any)
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for _, raw := range projects {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
// The Inbox sorts ahead of everything by its own rule; it is not part of
|
||||||
|
// what was reordered.
|
||||||
|
if p["is_inbox"] == true {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got = append(got, p["id"].(string))
|
||||||
|
}
|
||||||
|
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
|
||||||
|
t.Errorf("order = %v, want %v", got, reversed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort_order is a column on the project, not a per-viewer preference, so
|
||||||
|
// reordering must not rearrange a project somebody else owns.
|
||||||
|
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||||
|
theirID := theirs["id"].(string)
|
||||||
|
|
||||||
|
_, before := other.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
var wanted float64
|
||||||
|
for _, raw := range before["projects"].([]any) {
|
||||||
|
if p := raw.(map[string]any); p["id"] == theirID {
|
||||||
|
wanted = p["sort_order"].(float64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask, as somebody else, to put their project first.
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
|
||||||
|
"ids": []string{theirID},
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := other.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
for _, raw := range after["projects"].([]any) {
|
||||||
|
if p := raw.(map[string]any); p["id"] == theirID {
|
||||||
|
if p["sort_order"] != wanted {
|
||||||
|
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
|
||||||
|
wanted, p["sort_order"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The member list errored on every project — the ORDER BY named an expression,
|
||||||
|
// which SQLite refuses across a UNION — so the share panel had never shown
|
||||||
|
// anybody, not even the owner.
|
||||||
|
func TestListMembersWorksAtAll(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
members, _ := body["members"].([]any)
|
||||||
|
if len(members) != 1 {
|
||||||
|
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
|
||||||
|
}
|
||||||
|
if m := members[0].(map[string]any); m["role"] != "owner" {
|
||||||
|
t.Errorf("role = %v, want owner", m["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the Inbox, which is the one every task drawer asks about.
|
||||||
|
func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
var inboxID string
|
||||||
|
for _, raw := range list["projects"].([]any) {
|
||||||
|
if p := raw.(map[string]any); p["is_inbox"] == true {
|
||||||
|
inboxID = p["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A role sent by mistake used to be uncorrectable: the only way back was to remove
|
||||||
|
// the person and invite them again, which unassigns every task they were
|
||||||
|
// responsible for. Fixing a dropdown should not cost somebody their work.
|
||||||
|
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
member := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
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": "anden@example.dk", "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, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
owner, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
memberUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
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": "anden@example.dk", "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, "tredje@example.dk", "Tredje")
|
||||||
|
_ = stranger
|
||||||
|
strangerUser, _ := ts.db.UserByEmail(t.Context(), "tredje@example.dk")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,19 @@ func (s *Server) publish(projectID, event string, payload any) {
|
|||||||
s.hub.Publish(projectID, event, payload)
|
s.hub.Publish(projectID, event, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishToOwner sends a change that belongs to a person rather than to a
|
||||||
|
// project: their projects, their labels, their saved filters.
|
||||||
|
//
|
||||||
|
// Project membership is what the socket subscribes to, and a project that has
|
||||||
|
// just been created has no subscribers — the person is not watching a thing that
|
||||||
|
// did not exist a moment ago. So these go to the user instead, which also
|
||||||
|
// reaches their other devices.
|
||||||
|
func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||||
|
if user := userFrom(r.Context()); user != nil {
|
||||||
|
s.hub.PublishToUser(user.ID, event, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleWebSocket is the live-sync connection.
|
// handleWebSocket is the live-sync connection.
|
||||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
"github.com/kristianwind/verdande/internal/mail"
|
"github.com/kristianwind/verdande/internal/mail"
|
||||||
"github.com/kristianwind/verdande/internal/mcp"
|
"github.com/kristianwind/verdande/internal/mcp"
|
||||||
@@ -39,6 +40,12 @@ type Server struct {
|
|||||||
mcp *mcp.Server
|
mcp *mcp.Server
|
||||||
updates *update.Checker
|
updates *update.Checker
|
||||||
|
|
||||||
|
// 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
|
// Password guessing is the attack this application is actually exposed to, so
|
||||||
// the endpoints that check a secret are limited separately and more tightly
|
// the endpoints that check a secret are limited separately and more tightly
|
||||||
// than the rest of the API.
|
// than the rest of the API.
|
||||||
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
resetLimiter: newLimiter(5, time.Hour),
|
resetLimiter: newLimiter(5, time.Hour),
|
||||||
}
|
}
|
||||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
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.mcp = s.buildMCP()
|
||||||
s.updates = update.New(Version, cfg.UpdateCheck)
|
s.updates = update.New(Version, cfg.UpdateCheck)
|
||||||
|
|
||||||
@@ -90,6 +102,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// authenticated by the token in the recipient address.
|
// authenticated by the token in the recipient address.
|
||||||
r.Post("/inbound/mail", s.handleInboundMail)
|
r.Post("/inbound/mail", s.handleInboundMail)
|
||||||
|
|
||||||
|
// MCP with the token in the query string, for clients that cannot send a
|
||||||
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
|
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
|
||||||
|
// it at all. Same reasoning as the calendar feed above: a client that cannot
|
||||||
|
// be told to send a header means the token in the URL is the credential.
|
||||||
|
//
|
||||||
|
// Outside /api/v1 deliberately, because it must not be reachable with a
|
||||||
|
// session cookie. A cookie-authenticated POST with no CSRF check is a
|
||||||
|
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
|
||||||
|
// nothing else.
|
||||||
|
r.Post("/mcp", s.handleMCPWithKey)
|
||||||
|
|
||||||
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
||||||
// top-level navigation rather than a fetch, but still behind the session — the
|
// top-level navigation rather than a fetch, but still behind the session — the
|
||||||
// tokens it exchanges belong to whoever is signed in.
|
// tokens it exchanges belong to whoever is signed in.
|
||||||
@@ -120,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Method(http.MethodPost, "/password/reset",
|
r.Method(http.MethodPost, "/password/reset",
|
||||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
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
|
// The second step of a login: a session that has passed the password
|
||||||
// but not yet the code, and can reach nothing else.
|
// but not yet the code, and can reach nothing else.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -132,6 +164,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Use(s.requireAuth)
|
r.Use(s.requireAuth)
|
||||||
r.Get("/me", s.handleMe)
|
r.Get("/me", s.handleMe)
|
||||||
r.Patch("/me", s.handleUpdateProfile)
|
r.Patch("/me", s.handleUpdateProfile)
|
||||||
|
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||||
r.Post("/logout", s.handleLogout)
|
r.Post("/logout", s.handleLogout)
|
||||||
r.Post("/password/change", s.handleChangePassword)
|
r.Post("/password/change", s.handleChangePassword)
|
||||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||||
@@ -139,6 +172,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||||
|
|
||||||
|
// Behind requireSession, like the API tokens and for the same
|
||||||
|
// reason: a leaked token must not be able to end the sessions of
|
||||||
|
// the person it was stolen from, or list their devices.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.requireSession)
|
||||||
|
r.Get("/sessions", s.handleListSessions)
|
||||||
|
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,6 +266,17 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Delete("/", s.handleDisconnectGmail)
|
r.Delete("/", s.handleDisconnectGmail)
|
||||||
r.Post("/authorize", s.handleGmailAuthorize)
|
r.Post("/authorize", s.handleGmailAuthorize)
|
||||||
r.Post("/sync", s.handleGmailSyncNow)
|
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) {
|
r.Route("/import", func(r chi.Router) {
|
||||||
@@ -242,11 +304,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||||
})
|
})
|
||||||
r.Get("/upcoming", s.handleUpcoming)
|
r.Get("/upcoming", s.handleUpcoming)
|
||||||
|
r.Get("/delegated", s.handleDelegated)
|
||||||
|
r.Get("/people", s.handlePeople)
|
||||||
r.Get("/search", s.handleSearch)
|
r.Get("/search", s.handleSearch)
|
||||||
|
|
||||||
r.Route("/projects", func(r chi.Router) {
|
r.Route("/projects", func(r chi.Router) {
|
||||||
r.Get("/", s.handleListProjects)
|
r.Get("/", s.handleListProjects)
|
||||||
r.Post("/", s.handleCreateProject)
|
r.Post("/", s.handleCreateProject)
|
||||||
|
// Above /{projectID}, or chi reads "reorder" as an id.
|
||||||
|
r.Post("/reorder", s.handleReorderProjects)
|
||||||
|
|
||||||
r.Route("/{projectID}", func(r chi.Router) {
|
r.Route("/{projectID}", func(r chi.Router) {
|
||||||
// Read access is the floor for the whole subtree; the writes
|
// Read access is the floor for the whole subtree; the writes
|
||||||
@@ -268,14 +334,60 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Patch("/", s.handleUpdateProject)
|
r.Patch("/", s.handleUpdateProject)
|
||||||
r.Delete("/", s.handleDeleteProject)
|
r.Delete("/", s.handleDeleteProject)
|
||||||
r.Post("/invites", s.handleInvite)
|
r.Post("/invites", s.handleInvite)
|
||||||
|
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restoring is outside the block above: a trashed project is invisible
|
// The instance's membership. Administrators only, and behind
|
||||||
// to the permission check by design, so the handler resolves ownership
|
// requireSession as well as requireAdmin: an API token belongs to a
|
||||||
// itself.
|
// 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.
|
||||||
|
r.Route("/project-groups", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleListProjectGroups)
|
||||||
|
r.Post("/", s.handleCreateProjectGroup)
|
||||||
|
// Above /{groupID}, or chi reads "reorder" as an id.
|
||||||
|
r.Post("/reorder", s.handleReorderProjectGroups)
|
||||||
|
r.Get("/{groupID}", s.handleGetProjectGroup)
|
||||||
|
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
|
||||||
|
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
|
||||||
|
r.Post("/{groupID}/attachments", s.handleUploadGroupAttachment)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The trash is outside the block above: a deleted project is invisible
|
||||||
|
// to the permission check by design, so these handlers resolve
|
||||||
|
// ownership themselves.
|
||||||
|
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||||
|
|
||||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||||
@@ -336,7 +448,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
// The version travels with the health check because this is the one endpoint
|
||||||
|
// that answers without a session. "Which version is actually running?" is
|
||||||
|
// otherwise a question you cannot ask from outside — and after a deploy it is
|
||||||
|
// the first question there is.
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": Version})
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveWeb serves the PWA, falling back to index.html so that client-side routes
|
// serveWeb serves the PWA, falling back to index.html so that client-side routes
|
||||||
@@ -348,6 +464,21 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
|
||||||
|
// Handing back the shell there does not merely waste a response: it tells
|
||||||
|
// whoever asked that the thing they were looking for exists.
|
||||||
|
//
|
||||||
|
// That is not hypothetical. Claude probes
|
||||||
|
// /.well-known/oauth-authorization-server before connecting to an MCP server,
|
||||||
|
// read 200 and a page of HTML as "there is an authorization server here", and
|
||||||
|
// then failed trying to register with it — reporting a broken sign-in service
|
||||||
|
// on a server that has none and needs none. A 404 means "no OAuth here", the
|
||||||
|
// client stops asking, and the key in the URL is used as intended.
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if s.web == nil {
|
if s.web == nil {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Where a person is signed in, and how to end one of them.
|
||||||
|
//
|
||||||
|
// `last_seen_at` has been written on every request since sessions existed —
|
||||||
|
// throttled to once a minute, precisely so this list could say "this device, 2
|
||||||
|
// minutes ago" — and nothing has ever read it. A session you cannot see is a
|
||||||
|
// session you cannot end.
|
||||||
|
|
||||||
|
type sessionJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Current marks the one making the request, so the interface can say "denne
|
||||||
|
// enhed" rather than making somebody work it out from a user agent.
|
||||||
|
Current bool `json:"current"`
|
||||||
|
// Device is a summary; UserAgent is what it was summarised from. Both, because
|
||||||
|
// the summary is what you read and the original is what settles the question
|
||||||
|
// when the summary is not enough.
|
||||||
|
Device string `json:"device"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastSeenAt string `json:"last_seen_at"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSessions lists the caller's live sessions, most recently used first.
|
||||||
|
//
|
||||||
|
// The id here is the stored hash of the cookie, never the cookie itself: it cannot
|
||||||
|
// be pasted into a browser, and it is what the delete below needs.
|
||||||
|
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
current := sessionFrom(r.Context())
|
||||||
|
|
||||||
|
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list sessions", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]sessionJSON, 0, len(sessions))
|
||||||
|
for _, sess := range sessions {
|
||||||
|
out = append(out, sessionJSON{
|
||||||
|
ID: sess.ID,
|
||||||
|
Current: current != nil && sess.ID == current.ID,
|
||||||
|
Device: describeUserAgent(sess.UA),
|
||||||
|
UserAgent: sess.UA,
|
||||||
|
IP: sess.IP,
|
||||||
|
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
||||||
|
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
|
||||||
|
ExpiresAt: sess.ExpiresAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteSession signs one device out. Ending the current one is allowed and
|
||||||
|
// is simply logging out — refusing it would be a rule with no reason behind it,
|
||||||
|
// and somebody clearing every device wants this one gone too.
|
||||||
|
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
sessionID := chi.URLParam(r, "sessionID")
|
||||||
|
|
||||||
|
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
||||||
|
s.storeError(w, r, "delete session", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
||||||
|
s.clearSessionCookie(w)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeUserAgent turns a user-agent string into something a person recognises.
|
||||||
|
//
|
||||||
|
// Deliberately a handful of substring tests rather than a parsing library. The
|
||||||
|
// question this answers is "is one of these me?", and for that a wrong guess
|
||||||
|
// between two Chromium forks costs nothing while a dependency that has to be kept
|
||||||
|
// current costs something every year. The raw string is sent alongside, so
|
||||||
|
// anything this gets wrong is still visible.
|
||||||
|
//
|
||||||
|
// Order matters: every browser's user agent claims to be several other browsers,
|
||||||
|
// so the most specific name has to be tested first.
|
||||||
|
func describeUserAgent(ua string) string {
|
||||||
|
if strings.TrimSpace(ua) == "" {
|
||||||
|
return "Ukendt enhed"
|
||||||
|
}
|
||||||
|
|
||||||
|
browser := ""
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ua, "Edg/"):
|
||||||
|
browser = "Edge"
|
||||||
|
case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"):
|
||||||
|
browser = "Opera"
|
||||||
|
case strings.Contains(ua, "Firefox/"):
|
||||||
|
browser = "Firefox"
|
||||||
|
case strings.Contains(ua, "Chrome/"):
|
||||||
|
browser = "Chrome"
|
||||||
|
case strings.Contains(ua, "Safari/"):
|
||||||
|
browser = "Safari"
|
||||||
|
}
|
||||||
|
|
||||||
|
system := ""
|
||||||
|
switch {
|
||||||
|
// Before "Mac OS X": an iPhone's user agent says both.
|
||||||
|
case strings.Contains(ua, "iPhone"):
|
||||||
|
system = "iPhone"
|
||||||
|
case strings.Contains(ua, "iPad"):
|
||||||
|
system = "iPad"
|
||||||
|
case strings.Contains(ua, "Android"):
|
||||||
|
system = "Android"
|
||||||
|
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
|
||||||
|
system = "macOS"
|
||||||
|
case strings.Contains(ua, "Windows"):
|
||||||
|
system = "Windows"
|
||||||
|
case strings.Contains(ua, "Linux"):
|
||||||
|
system = "Linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case browser != "" && system != "":
|
||||||
|
return browser + " på " + system
|
||||||
|
case browser != "":
|
||||||
|
return browser
|
||||||
|
case system != "":
|
||||||
|
return system
|
||||||
|
}
|
||||||
|
// Nothing recognised: show the string itself rather than "Ukendt". A CalDAV
|
||||||
|
// client or a script is a real session, and its own name is more useful than a
|
||||||
|
// word that says the server gave up.
|
||||||
|
return truncateRunes(ua, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, max int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max]) + "…"
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,9 +33,14 @@ type taskJSON struct {
|
|||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
SortOrder float64 `json:"sort_order"`
|
// What the row can say without being opened. Sent with every task rather than
|
||||||
CreatedAt string `json:"created_at"`
|
// fetched when one is opened, because the point is that you see it in the list.
|
||||||
UpdatedAt string `json:"updated_at"`
|
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 {
|
func toTaskJSON(t store.Task) taskJSON {
|
||||||
@@ -43,9 +50,11 @@ func toTaskJSON(t store.Task) taskJSON {
|
|||||||
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
|
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
|
||||||
RepeatText: recurrence.Describe(t.RecurrenceRule),
|
RepeatText: recurrence.Describe(t.RecurrenceRule),
|
||||||
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
|
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
|
||||||
SortOrder: t.SortOrder,
|
SubtaskCount: t.SubtaskCount, SubtaskDone: t.SubtaskDone,
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
AttachmentCount: t.AttachmentCount,
|
||||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
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.
|
// Never nil: a client iterating labels should not have to null-check first.
|
||||||
Labels: t.Labels,
|
Labels: t.Labels,
|
||||||
}
|
}
|
||||||
@@ -97,7 +106,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list tasks", err)
|
s.internal(w, r, "list tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||||
@@ -114,7 +123,7 @@ func toTaskList(tasks []store.Task) []taskJSON {
|
|||||||
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
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)
|
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
@@ -160,7 +169,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
var err error
|
var err error
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
s.internal(w, "inbox", err)
|
s.internal(w, r, "inbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,7 +233,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
// The store wrote the labels; the struct it was handed does not know that, and
|
// The store wrote the labels; the struct it was handed does not know that, and
|
||||||
@@ -249,6 +258,16 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A change of project is a move, not a field: it has to renumber the task
|
||||||
|
// among its new neighbours. Done before the rest so a request that both moves
|
||||||
|
// and edits ends up entirely applied or entirely refused.
|
||||||
|
if req.ProjectID != nil {
|
||||||
|
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u := store.TaskUpdate{
|
u := store.TaskUpdate{
|
||||||
Description: req.Description, Priority: req.Priority,
|
Description: req.Description, Priority: req.Priority,
|
||||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||||
@@ -290,13 +309,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||||
@@ -315,13 +334,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "complete task", err)
|
s.storeError(w, r, "complete task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,13 +376,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
||||||
s.storeError(w, "reopen task", err)
|
s.storeError(w, r, "reopen task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
||||||
@@ -382,11 +401,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||||
s.storeError(w, "delete task", err)
|
s.storeError(w, r, "delete task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
||||||
@@ -415,7 +434,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
@@ -437,12 +456,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "get task", err)
|
s.storeError(w, r, "get task", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
||||||
@@ -456,6 +475,10 @@ type quickAddRequest struct {
|
|||||||
// ProjectID is the view the user was looking at. A "#project" in the text
|
// 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.
|
// wins over it: what somebody typed beats where they happened to be standing.
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
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) {
|
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -473,17 +496,24 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectID := req.ProjectID
|
projectID := req.ProjectID
|
||||||
|
// Set when the text named a project that does not exist. The task is still
|
||||||
|
// created — see below — but the caller is told, because the alternative is
|
||||||
|
// what it used to be: "#Dekoration" disappears from the title, the task lands
|
||||||
|
// somewhere else, and nothing anywhere says why.
|
||||||
|
unknownProject := ""
|
||||||
if parsed.Project != "" {
|
if parsed.Project != "" {
|
||||||
// An unknown "#name" is not an error: the task still gets created, in the
|
// An unknown "#name" is not an error: the task still gets created, in the
|
||||||
// Inbox, rather than a thought being thrown away over a typo.
|
// Inbox, rather than a thought being thrown away over a typo.
|
||||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||||
projectID = id
|
projectID = id
|
||||||
|
} else {
|
||||||
|
unknownProject = parsed.Project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
var err error
|
var err error
|
||||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
s.internal(w, "inbox", err)
|
s.internal(w, r, "inbox", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
t := &store.Task{
|
||||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
||||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||||
|
RecurrenceRule: parsed.Recurrence,
|
||||||
}
|
}
|
||||||
if parsed.DueDate != "" {
|
if parsed.DueDate != "" {
|
||||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||||
@@ -504,12 +556,23 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
t.Labels = parsed.Labels
|
t.Labels = parsed.Labels
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||||
|
|
||||||
|
if unknownProject != "" || unknownSection != "" {
|
||||||
|
// Alongside the task rather than instead of it: the task was created, and
|
||||||
|
// this says what could not be honoured while creating it.
|
||||||
|
writeJSON(w, http.StatusCreated, struct {
|
||||||
|
taskJSON
|
||||||
|
UnknownProject string `json:"unknown_project,omitempty"`
|
||||||
|
UnknownSection string `json:"unknown_section,omitempty"`
|
||||||
|
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +588,42 @@ func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// --- helpers --------------------------------------------------------------------
|
// --- helpers --------------------------------------------------------------------
|
||||||
|
|
||||||
|
// moveTaskToProject moves a task to another project, if that is really what was
|
||||||
|
// asked for.
|
||||||
|
//
|
||||||
|
// Shared by the REST handler and the MCP tool, because they had drifted: both
|
||||||
|
// accepted project_id on an update, both checked the caller's rights on the
|
||||||
|
// destination — which reads as "understood" — and then neither passed it on,
|
||||||
|
// because store.TaskUpdate has no such field. The task stayed where it was and
|
||||||
|
// the response said OK. An argument that is validated and then dropped is worse
|
||||||
|
// than one that is refused.
|
||||||
|
//
|
||||||
|
// A move rather than a field assignment: sort_order is per project, so landing
|
||||||
|
// in a new one means being given a position among its tasks.
|
||||||
|
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
|
||||||
|
if projectID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current, err := s.db.GetTask(ctx, taskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
if current.ProjectID == projectID {
|
||||||
|
return nil // already there; not worth a write or a renumber
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No neighbours: it lands at the start of the destination, which is the only
|
||||||
|
// answer available when the caller did not say where. Section is cleared —
|
||||||
|
// a section belongs to the project being left behind.
|
||||||
|
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateTaskContent(content string, priority *int) map[string]string {
|
func validateTaskContent(content string, priority *int) map[string]string {
|
||||||
fields := map[string]string{}
|
fields := map[string]string{}
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
|
|||||||
t.Errorf("unread = %v after marking everything read", after["unread"])
|
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) {
|
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||||
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list api tokens", err)
|
s.internal(w, r, "list api tokens", err)
|
||||||
return
|
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)
|
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "create api token", err)
|
s.internal(w, r, "create api token", err)
|
||||||
return
|
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) {
|
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeError(w, "delete api token", err)
|
s.storeError(w, r, "delete api token", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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)
|
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate totp secret", err)
|
s.internal(w, r, "generate totp secret", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Stored but not enabled: confirm decides that.
|
// Stored but not enabled: confirm decides that.
|
||||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
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()
|
codes, hashes, err := auth.NewRecoveryCodes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate recovery codes", err)
|
s.internal(w, r, "generate recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
||||||
@@ -116,7 +116,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
|||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
if !user.TOTPEnabled {
|
if !user.TOTPEnabled {
|
||||||
writeError(w, http.StatusConflict, CodeConflict,
|
writeError(w, http.StatusConflict, CodeTOTPNotEnabled,
|
||||||
"two-factor authentication is not switched on")
|
"two-factor authentication is not switched on")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -127,11 +127,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
codes, hashes, err := auth.NewRecoveryCodes()
|
codes, hashes, err := auth.NewRecoveryCodes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "generate recovery codes", err)
|
s.internal(w, r, "generate recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
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) {
|
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
|
||||||
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "count recovery codes", err)
|
s.internal(w, r, "count recovery codes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
|
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": "ny@example.dk"})
|
||||||
|
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"] != "ny@example.dk" {
|
||||||
|
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"] == "ny@example.dk" && 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(), "kristian@example.dk")
|
||||||
|
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, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
other, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
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, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||||
|
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": "anden@example.dk", "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, "anden@example.dk", "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": "x@y.dk"}},
|
||||||
|
{"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(), "kristian@example.dk")
|
||||||
|
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": "forkert@example.dk"})
|
||||||
|
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": "kristian@example.dk"})
|
||||||
|
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, "anden@example.dk", "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,
|
Limit: 500,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "today", err)
|
s.internal(w, r, "today", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: 1000,
|
Limit: 1000,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "upcoming", err)
|
s.internal(w, r, "upcoming", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +92,104 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handlePeople lists everybody the caller shares a project with, so an interface
|
||||||
|
// can put a name and a face on an assignee id.
|
||||||
|
//
|
||||||
|
// One request per session rather than a lookup per row, and not the instance's
|
||||||
|
// user list — that is the administrator's page. A task list has no business
|
||||||
|
// enumerating everybody with an account here.
|
||||||
|
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
|
||||||
|
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "list people", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]personJSON, 0, len(people))
|
||||||
|
for _, p := range people {
|
||||||
|
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"people": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type personJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type delegatedPerson struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
Tasks []taskJSON `json:"tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDelegated is "waiting on others": everything assigned to somebody who is
|
||||||
|
// not the caller, in the projects the caller can see.
|
||||||
|
//
|
||||||
|
// Grouped by person rather than returned flat, because the question this view
|
||||||
|
// answers is about people — "what is Anders sitting on" — and a client grouping it
|
||||||
|
// itself would need every assignee's name, which it has no way to ask for. The
|
||||||
|
// names come back with the tasks, so one request is one screen.
|
||||||
|
//
|
||||||
|
// People are ordered by name, and their tasks by whatever the list query decided,
|
||||||
|
// which puts the overdue and the soonest first.
|
||||||
|
func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
|
DelegatedBy: user.ID,
|
||||||
|
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "delegated", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
byPerson := map[string][]taskJSON{}
|
||||||
|
ids := []string{}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if _, seen := byPerson[t.AssigneeID]; !seen {
|
||||||
|
ids = append(ids, t.AssigneeID)
|
||||||
|
}
|
||||||
|
byPerson[t.AssigneeID] = append(byPerson[t.AssigneeID], toTaskJSON(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
people, err := s.db.PeopleByIDs(r.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "delegated people", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]delegatedPerson, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
// A person who no longer has an account still has their id on the task.
|
||||||
|
// Naming them "Ukendt" beats dropping the tasks, which would make work
|
||||||
|
// disappear from the one view that exists to stop that happening.
|
||||||
|
p, ok := people[id]
|
||||||
|
if !ok {
|
||||||
|
p = store.Person{ID: id, Name: "Ukendt", AvatarColor: "#8a8f98"}
|
||||||
|
}
|
||||||
|
out = append(out, delegatedPerson{
|
||||||
|
UserID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor, Tasks: byPerson[id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sortByName(out)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"people": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortByName orders the groups the way a list of people reads. Insertion sort:
|
||||||
|
// this is a handful of entries, and it keeps the package free of a dependency on
|
||||||
|
// sort just for this.
|
||||||
|
func sortByName(people []delegatedPerson) {
|
||||||
|
for i := 1; i < len(people); i++ {
|
||||||
|
for j := i; j > 0 && lower(people[j].Name) < lower(people[j-1].Name); j-- {
|
||||||
|
people[j], people[j-1] = people[j-1], people[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleSearch is what Cmd+K calls. It searches across every project the user can
|
// handleSearch is what Cmd+K calls. It searches across every project the user can
|
||||||
// see, which is the point — the thing you are looking for is usually somewhere you
|
// see, which is the point — the thing you are looking for is usually somewhere you
|
||||||
// were not looking.
|
// were not looking.
|
||||||
@@ -111,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "search tasks", err)
|
s.internal(w, r, "search tasks", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
// second FTS index for that would be more machinery than the problem needs.
|
// second FTS index for that would be more machinery than the problem needs.
|
||||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "search projects", err)
|
s.internal(w, r, "search projects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
matched := []projectJSON{}
|
matched := []projectJSON{}
|
||||||
@@ -141,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
|
|||||||
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
||||||
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, "list activity", err)
|
s.internal(w, r, "list activity", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A label is how you find a task whose wording you have forgotten. Search
|
||||||
|
// covered the text only, so "regnskab" found nothing even with the task
|
||||||
|
// labelled @regnskab in front of you.
|
||||||
|
func TestSearchFindsTasksByLabel(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "ring til revisoren @regnskab"})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "køb kaffe"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||||
|
tasks, _ := body["tasks"].([]any)
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Fatalf("found %d tasks searching for a label, want 1: %v", len(tasks), body)
|
||||||
|
}
|
||||||
|
if got := tasks[0].(map[string]any)["content"]; got != "ring til revisoren" {
|
||||||
|
t.Errorf("found %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The text search still works, and a label match does not swallow it.
|
||||||
|
func TestSearchStillFindsTasksByText(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": "køb grøn maling"})
|
||||||
|
|
||||||
|
// Diacritic folding still applies to the text side.
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
|
||||||
|
if tasks, _ := body["tasks"].([]any); len(tasks) != 1 {
|
||||||
|
t.Errorf("searching \"gron\" found %d tasks, want 1", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's label must not pull their task into your results — the label
|
||||||
|
// join has to stay inside the visibility the outer query already applies.
|
||||||
|
func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
other.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "deres hemmelighed @regnskab"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||||
|
if tasks, _ := body["tasks"].([]any); len(tasks) != 0 {
|
||||||
|
t.Errorf("found somebody else's task: %v", tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Waiting on others" is the other half of an assignee filter, and the half that
|
||||||
|
// is easy to get backwards: what somebody has handed over, not what they have
|
||||||
|
// been handed.
|
||||||
|
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
|
||||||
|
anders, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
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": "anders@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
|
||||||
|
})
|
||||||
|
// Mine, and unassigned: neither belongs in a view about other people.
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "ingen har den", "project_id": projectID,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
people, _ := body["people"].([]any)
|
||||||
|
if len(people) != 1 {
|
||||||
|
t.Fatalf("want one person, got %v", people)
|
||||||
|
}
|
||||||
|
person := people[0].(map[string]any)
|
||||||
|
if person["name"] != "Anders" {
|
||||||
|
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
|
||||||
|
person["name"])
|
||||||
|
}
|
||||||
|
if person["avatar_color"] == nil || person["avatar_color"] == "" {
|
||||||
|
t.Errorf("no avatar colour: %v", person)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, _ := person["tasks"].([]any)
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Fatalf("want one task, got %v", tasks)
|
||||||
|
}
|
||||||
|
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
|
||||||
|
t.Errorf("content = %v", tasks[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The view is per person: Anders is not waiting on himself, and he cannot see
|
||||||
|
// what somebody has delegated in a project he is not in.
|
||||||
|
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
anders := ts.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
|
||||||
|
andersUser, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project Anders is not a member of.
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "anders@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// It is assigned to him, so it is not something he is waiting on.
|
||||||
|
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
|
||||||
|
if people, _ := body["people"].([]any); len(people) != 0 {
|
||||||
|
t.Errorf("Anders is waiting on himself: %v", people)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And somebody with no access to the project sees none of it.
|
||||||
|
other := ts.newUser(t, "tredje@example.dk", "Tredje")
|
||||||
|
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
|
||||||
|
if people, _ := third["people"].([]any); len(people) != 0 {
|
||||||
|
t.Errorf("a task in a project they cannot see: %v", people)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /people is what puts a name on an assignee id. It must be the people you share
|
||||||
|
// work with — not the instance's user list, which is the administrator's page.
|
||||||
|
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
colleague := ts.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
ts.newUser(t, "fremmed@example.dk", "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": "anders@example.dk", "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,120 @@
|
|||||||
|
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) {
|
||||||
|
// The argument of one of the three, quoted or backticked. Deliberately narrow:
|
||||||
|
// a general scan of every string literal in a Svelte script block would drown
|
||||||
|
// in class names, keys and query fragments.
|
||||||
|
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(\s*['` + "`" + `]([^'` + "`" + `]{4,})['` + "`" + `]`)
|
||||||
|
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|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)
|
||||||
|
for _, m := range call.FindAllStringSubmatch(string(body), -1) {
|
||||||
|
if danish.MatchString(m[1]) {
|
||||||
|
found = append(found, rel+": "+m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||||
|
// Text nodes of three characters or more, and the three attributes a person
|
||||||
|
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||||
|
// and is not what this is looking for.
|
||||||
|
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
|
||||||
|
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
|
||||||
|
// Words that are Danish and are not also English. "is", "for" and "to" would
|
||||||
|
// match English prose and are deliberately absent.
|
||||||
|
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
|
||||||
|
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||||
|
styles := regexp.MustCompile(`(?s)<style>.*`)
|
||||||
|
|
||||||
|
var found []string
|
||||||
|
root := filepath.Join("..", "..", "web", "src")
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".svelte") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
markup := string(body)
|
||||||
|
if at := strings.Index(markup, "</script>"); at > 0 {
|
||||||
|
markup = markup[at:]
|
||||||
|
}
|
||||||
|
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(root, path)
|
||||||
|
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
|
||||||
|
attrs.FindAllStringSubmatch(markup, -1)...) {
|
||||||
|
phrase := strings.TrimSpace(m[1])
|
||||||
|
if danish.MatchString(phrase) {
|
||||||
|
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk %s: %v", root, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(found) > 0 {
|
||||||
|
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
|
||||||
|
"t(). An English interface shows them in Danish, and neither dictionary knows "+
|
||||||
|
"they exist:\n %s", len(found), strings.Join(found, "\n "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ func (r *Runner) Start(ctx context.Context) {
|
|||||||
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
||||||
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
||||||
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
|
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
|
// 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
|
// 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
|
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||||
@@ -161,22 +162,41 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
|||||||
if time.Since(last) < 23*time.Hour {
|
if time.Since(last) < 23*time.Hour {
|
||||||
return nil
|
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()
|
started := time.Now()
|
||||||
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// A failed attempt can leave a partial file, which would then look like a
|
||||||
// backup to anybody reading the directory.
|
// backup to anybody reading the directory.
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
if recErr := r.db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
||||||
r.log.Warn("record backup failure", "err", recErr)
|
log.Warn("record backup failure", "err", recErr)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("snapshot: %w", err)
|
return fmt.Errorf("snapshot: %w", err)
|
||||||
}
|
}
|
||||||
@@ -185,13 +205,13 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
|||||||
if info, err := os.Stat(path); err == nil {
|
if info, err := os.Stat(path); err == nil {
|
||||||
size = info.Size()
|
size = info.Size()
|
||||||
}
|
}
|
||||||
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||||
r.log.Warn("record backup", "err", err)
|
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())
|
"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.
|
// rotateBackups keeps the most recent fourteen and deletes the rest.
|
||||||
@@ -199,8 +219,8 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
|||||||
// Counted by file, not by age: a container that was off for a month would
|
// 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
|
// 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.
|
// of them — leaving none at exactly the moment they were most likely to be needed.
|
||||||
func (r *Runner) rotateBackups() error {
|
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
|
||||||
entries, err := os.ReadDir(r.cfg.BackupsDir())
|
entries, err := os.ReadDir(cfg.BackupsDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -215,12 +235,12 @@ func (r *Runner) rotateBackups() error {
|
|||||||
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
|
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
|
||||||
|
|
||||||
for _, name := range backups[min(len(backups), backupKeepDays):] {
|
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 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
r.log.Info("old backup removed", "path", path)
|
log.Info("old backup removed", "path", path)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -265,6 +285,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (r *Runner) purgeSessions(ctx context.Context) error {
|
||||||
n, err := r.db.PurgeExpiredSessions(ctx)
|
n, err := r.db.PurgeExpiredSessions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -276,27 +310,12 @@ func (r *Runner) purgeSessions(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (r *Runner) RunBackupNow(ctx context.Context) error {
|
||||||
started := time.Now()
|
return Backup(ctx, r.cfg, r.db, r.log)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
t.Fatalf("rotate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const (
|
|||||||
KindTime Kind = "time"
|
KindTime Kind = "time"
|
||||||
KindPriority Kind = "priority"
|
KindPriority Kind = "priority"
|
||||||
KindProject Kind = "project"
|
KindProject Kind = "project"
|
||||||
|
KindSection Kind = "section"
|
||||||
KindLabel Kind = "label"
|
KindLabel Kind = "label"
|
||||||
KindRepeat Kind = "repeat"
|
KindRepeat Kind = "repeat"
|
||||||
)
|
)
|
||||||
@@ -52,7 +53,11 @@ type Result struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
// Project is the name written after '#', not an id: the caller resolves it
|
// 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.
|
// 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"`
|
Labels []string `json:"labels,omitempty"`
|
||||||
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
||||||
Priority int `json:"priority"`
|
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
|
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
|
||||||
// called "Fredag" from later being read as a weekday.
|
// called "Fredag" from later being read as a weekday.
|
||||||
res.Project, spans = extractProject(input, spans)
|
res.Project, spans = extractProject(input, spans)
|
||||||
|
res.Section, spans = extractSection(input, spans)
|
||||||
res.Labels, spans = extractLabels(input, spans)
|
res.Labels, spans = extractLabels(input, spans)
|
||||||
res.Priority, spans = extractPriority(input, spans)
|
res.Priority, spans = extractPriority(input, spans)
|
||||||
|
|
||||||
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
|
|||||||
var (
|
var (
|
||||||
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||||
reLabel = 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) {
|
func extractProject(input string, spans []Span) (string, []Span) {
|
||||||
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
|
|||||||
return name, spans
|
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) {
|
func extractLabels(input string, spans []Span) ([]string, []Span) {
|
||||||
var labels []string
|
var labels []string
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
|
|||||||
t.Error("no span of kind repeat")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
ID string
|
ID string
|
||||||
ProjectID 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
|
UserID string
|
||||||
UserName string
|
UserName string
|
||||||
Event string
|
Event string
|
||||||
@@ -19,6 +25,23 @@ type Activity struct {
|
|||||||
CreatedAt time.Time
|
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.
|
// RecordActivity appends to a project's history.
|
||||||
//
|
//
|
||||||
// Every mutation writes one, which is what makes the activity log complete rather
|
// 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,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
|
NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +66,12 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
|||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 50
|
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, `
|
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
|
SELECT a.id, a.project_id, a.task_id, COALESCE(a.user_id, ''), COALESCE(u.name, ''),
|
||||||
FROM activity a JOIN users u ON u.id = a.user_id
|
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 = ?
|
WHERE a.project_id = ?
|
||||||
ORDER BY a.created_at DESC, a.id DESC
|
ORDER BY a.created_at DESC, a.id DESC
|
||||||
LIMIT ?`, projectID, limit)
|
LIMIT ?`, projectID, limit)
|
||||||
@@ -72,6 +98,117 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
|||||||
return out, rows.Err()
|
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
|
// 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
|
// 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.
|
// 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 {
|
type Attachment struct {
|
||||||
ID string
|
ID string
|
||||||
TaskID string
|
TaskID string
|
||||||
CommentID 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
|
Filename string
|
||||||
MimeType string
|
MimeType string
|
||||||
Size int64
|
Size int64
|
||||||
@@ -154,17 +157,17 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
|
|||||||
a.CreatedAt = time.Now().UTC()
|
a.CreatedAt = time.Now().UTC()
|
||||||
|
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO attachments (id, task_id, comment_id, filename, mime_type, size,
|
`INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type,
|
||||||
path, uploaded_by, created_at)
|
size, path, uploaded_by, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
a.ID, nullString(a.TaskID), nullString(a.CommentID), a.Filename,
|
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
|
||||||
a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
||||||
rows, err := db.QueryContext(ctx,
|
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)
|
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -173,13 +176,26 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
|
|||||||
return scanAttachments(rows)
|
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) {
|
func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (map[string][]Attachment, error) {
|
||||||
args := make([]any, len(commentIDs))
|
args := make([]any, len(commentIDs))
|
||||||
for i, id := range commentIDs {
|
for i, id := range commentIDs {
|
||||||
args[i] = id
|
args[i] = id
|
||||||
}
|
}
|
||||||
rows, err := db.QueryContext(ctx,
|
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))+`)
|
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
|
||||||
ORDER BY created_at`, args...)
|
ORDER BY created_at`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -202,14 +218,15 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
|||||||
out := []Attachment{}
|
out := []Attachment{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Attachment
|
var a Attachment
|
||||||
var taskID, commentID sql.NullString
|
var taskID, commentID, groupID sql.NullString
|
||||||
var created int64
|
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 {
|
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a.TaskID = taskID.String
|
a.TaskID = taskID.String
|
||||||
a.CommentID = commentID.String
|
a.CommentID = commentID.String
|
||||||
|
a.GroupID = groupID.String
|
||||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
out = append(out, a)
|
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) {
|
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
|
||||||
rows, err := db.QueryContext(ctx,
|
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)
|
FROM attachments WHERE id = ?`, attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
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
|
// 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
|
// 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.
|
// live invite behind, which is a link that still works after it has been used.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Foldable groups over the projects in the sidebar.
|
||||||
|
--
|
||||||
|
-- A table rather than a `group_name` column on the project: a group has to be
|
||||||
|
-- renamable in one write, has to survive being emptied, and has to remember
|
||||||
|
-- whether it is folded. A repeated string on every project row can do none of
|
||||||
|
-- those — renaming would touch N rows, and an empty group would simply cease to
|
||||||
|
-- exist the moment its last project left it.
|
||||||
|
CREATE TABLE project_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- Folded state lives on the row, not in localStorage.
|
||||||
|
--
|
||||||
|
-- The sidebar's *width* is a property of the screen you are sitting at, which
|
||||||
|
-- is why that one is stored per browser. A folded group is not: it is a
|
||||||
|
-- statement about that group — "this is the part of my work I am not in right
|
||||||
|
-- now" — and it is true on the laptop and the desktop alike. A group is also
|
||||||
|
-- owned by exactly one person, so there is nobody else's answer to disagree
|
||||||
|
-- with.
|
||||||
|
collapsed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_groups_owner ON project_groups (owner_id);
|
||||||
|
|
||||||
|
-- ON DELETE SET NULL: deleting a group is a decision about the heading, not about
|
||||||
|
-- the projects underneath it. They fall back to ungrouped, in the order they
|
||||||
|
-- already had.
|
||||||
|
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_group ON projects (group_id) WHERE deleted_at IS NULL;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A colour on a sidebar group, so a group can be marked the way a project is.
|
||||||
|
--
|
||||||
|
-- Projects, labels and filters have carried a `color` since 0001; groups arrived
|
||||||
|
-- in 0005 without one because nothing rendered any of them yet. Now that the
|
||||||
|
-- sidebar paints them, a group without one would be the only thing in the list
|
||||||
|
-- that could not be told apart at a glance.
|
||||||
|
--
|
||||||
|
-- Same default and same spelling as the others: a colour name, not a hex. The
|
||||||
|
-- value has to survive a theme change and a redesign, and '#8a8f98' in ten
|
||||||
|
-- thousand rows is a decision nobody can revisit.
|
||||||
|
ALTER TABLE project_groups ADD COLUMN color TEXT NOT NULL DEFAULT 'graphite';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Server errors, kept where they can outlive the container that produced them.
|
||||||
|
--
|
||||||
|
-- verdande already logs the real cause of every 500 to stdout. On a Rune that is
|
||||||
|
-- the container's log, and every restart starts a new one — so the panel can
|
||||||
|
-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone.
|
||||||
|
-- An operator is then told that something broke and given no way to find out what.
|
||||||
|
--
|
||||||
|
-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that
|
||||||
|
-- grows without limit is a disk that fills for a reason nobody expected.
|
||||||
|
CREATE TABLE error_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
at INTEGER NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
-- what: the operation the handler was attempting, in the words it uses in the
|
||||||
|
-- log — "list projects", "move task". The detail a stack trace would give and
|
||||||
|
-- a status code cannot.
|
||||||
|
what TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
-- Whose request it was, so a fault that only one account hits is visible as
|
||||||
|
-- one. NULL for anything that failed before authentication.
|
||||||
|
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
request_id TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_error_log_at ON error_log (at DESC);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- `tasks.created_by` stops cascading.
|
||||||
|
--
|
||||||
|
-- It was ON DELETE CASCADE, which made deleting an account delete every task that
|
||||||
|
-- account ever wrote — including in projects somebody else owns. Retiring a
|
||||||
|
-- colleague therefore removed their contributions from shared work rather than
|
||||||
|
-- leaving it behind. It is the last remaining way to lose other people's work by
|
||||||
|
-- accident, and the administrator's page saying so in plain words is a warning, not
|
||||||
|
-- a fix.
|
||||||
|
--
|
||||||
|
-- SET NULL instead: the task stays and loses its author. `created_by` becomes
|
||||||
|
-- nullable to make that expressible, which is the same shape `assignee_id` and
|
||||||
|
-- `completed_by` already have for the same reason.
|
||||||
|
--
|
||||||
|
-- SQLite cannot alter a foreign key action, so the table is rebuilt. The FTS
|
||||||
|
-- triggers come along: they are dropped first so that DROP TABLE and ALTER TABLE
|
||||||
|
-- see a schema they can parse, and recreated below. `tasks_fts` is an
|
||||||
|
-- external-content table keyed on `tasks.rowid`, and a rebuilt table hands out new
|
||||||
|
-- rowids, so the index is rebuilt at the end rather than left pointing at rows that
|
||||||
|
-- have moved.
|
||||||
|
--
|
||||||
|
-- verdande:rebuild-tables
|
||||||
|
|
||||||
|
DROP TRIGGER tasks_fts_insert;
|
||||||
|
DROP TRIGGER tasks_fts_delete;
|
||||||
|
DROP TRIGGER tasks_fts_update;
|
||||||
|
|
||||||
|
CREATE TABLE tasks_rebuilt (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
|
||||||
|
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 4,
|
||||||
|
due_date TEXT,
|
||||||
|
due_datetime INTEGER,
|
||||||
|
due_timezone TEXT,
|
||||||
|
duration_min INTEGER,
|
||||||
|
recurrence_rule TEXT,
|
||||||
|
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
-- Nullable, and SET NULL: an account can go without its work going with it.
|
||||||
|
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
completed_at INTEGER,
|
||||||
|
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
sort_order REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
fold TEXT GENERATED ALWAYS AS (
|
||||||
|
replace(replace(replace(replace(replace(replace(
|
||||||
|
content || ' ' || description,
|
||||||
|
'ø', 'o'), 'Ø', 'o'),
|
||||||
|
'æ', 'ae'), 'Æ', 'ae'),
|
||||||
|
'å', 'aa'), 'Å', 'aa')
|
||||||
|
) STORED,
|
||||||
|
CHECK (priority BETWEEN 1 AND 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tasks_rebuilt
|
||||||
|
(id, project_id, section_id, parent_id, content, description, priority,
|
||||||
|
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
|
||||||
|
assignee_id, created_by, completed_at, completed_by, sort_order,
|
||||||
|
created_at, updated_at, deleted_at)
|
||||||
|
SELECT
|
||||||
|
id, project_id, section_id, parent_id, content, description, priority,
|
||||||
|
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
|
||||||
|
assignee_id, created_by, completed_at, completed_by, sort_order,
|
||||||
|
created_at, updated_at, deleted_at
|
||||||
|
FROM tasks;
|
||||||
|
|
||||||
|
DROP TABLE tasks;
|
||||||
|
|
||||||
|
ALTER TABLE tasks_rebuilt RENAME TO tasks;
|
||||||
|
|
||||||
|
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
|
||||||
|
WHERE deleted_at IS NULL AND completed_at IS NULL;
|
||||||
|
CREATE INDEX idx_tasks_due ON tasks (due_date)
|
||||||
|
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
|
||||||
|
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
|
||||||
|
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN
|
||||||
|
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN
|
||||||
|
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||||||
|
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||||||
|
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The rowids the index was built against are gone with the old table.
|
||||||
|
INSERT INTO tasks_fts (tasks_fts) VALUES ('rebuild');
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- `activity.user_id` stops cascading, and the log gets an index it can be read by.
|
||||||
|
--
|
||||||
|
-- Two changes with the same cause: there is now an instance-wide audit log, and
|
||||||
|
-- the table it reads was only ever built to answer "what happened in this
|
||||||
|
-- project".
|
||||||
|
--
|
||||||
|
-- The cascade first. An audit log whose rows disappear when the account they
|
||||||
|
-- describe is deleted answers "what has happened on this server" with everything
|
||||||
|
-- except the part somebody had a reason to remove. That is the wrong answer to the
|
||||||
|
-- question, and it is the same mistake migration 0008 fixed on `tasks.created_by`:
|
||||||
|
-- the record outlives the person. SET NULL, and the reader shows "en slettet
|
||||||
|
-- konto" rather than a name.
|
||||||
|
--
|
||||||
|
-- `project_id` keeps cascading on purpose. A project purged from the trash is
|
||||||
|
-- genuinely gone — the trash is the undo, and past it the deletion was deliberate
|
||||||
|
-- and complete. Activity has its own retention window besides.
|
||||||
|
--
|
||||||
|
-- Then the index. `idx_activity_project` leads with project_id, so a query that
|
||||||
|
-- wants the newest rows across every project cannot use it and scans the table to
|
||||||
|
-- sort it. The new one is ordered the way the audit log reads, and the compound
|
||||||
|
-- (created_at, id) matches the keyset the handler pages with — two rows written in
|
||||||
|
-- the same second must not be able to hide each other across a page boundary.
|
||||||
|
--
|
||||||
|
-- The marker below is not here because the drop would cascade: nothing references
|
||||||
|
-- `activity`, so it takes nothing with it. It is here for the check that comes
|
||||||
|
-- with it. With foreign keys on, the copy is validated a row at a time against a
|
||||||
|
-- schema that is half rebuilt, and the first bad row aborts with no idea how many
|
||||||
|
-- others are like it. With them off, `PRAGMA foreign_key_check` runs once before
|
||||||
|
-- the commit and names what is actually wrong — and this runs against a live
|
||||||
|
-- database on somebody's next restart.
|
||||||
|
--
|
||||||
|
-- verdande:rebuild-tables
|
||||||
|
|
||||||
|
CREATE TABLE activity_rebuilt (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL,
|
||||||
|
-- Nullable: the record of what was done outlives whoever did it.
|
||||||
|
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO activity_rebuilt (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||||
|
SELECT id, project_id, task_id, user_id, event, payload_json, created_at FROM activity;
|
||||||
|
|
||||||
|
DROP TABLE activity;
|
||||||
|
|
||||||
|
ALTER TABLE activity_rebuilt RENAME TO activity;
|
||||||
|
|
||||||
|
CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_activity_recent ON activity (created_at DESC, id DESC);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Which of the sidebar's fixed headings a person has folded away.
|
||||||
|
--
|
||||||
|
-- A project group already carries `collapsed` on its own row, and the reasoning
|
||||||
|
-- there applies here too: folding a heading is a statement about the work, not
|
||||||
|
-- about the screen. "I am not in Etiketter at the moment" is as true on the laptop
|
||||||
|
-- as on the desktop, which is what separates this from the sidebar's *width* —
|
||||||
|
-- that one is in localStorage, because a narrow laptop and a wide monitor deserve
|
||||||
|
-- different answers and syncing it would make one of them wrong every time you
|
||||||
|
-- moved.
|
||||||
|
--
|
||||||
|
-- A JSON array of keys rather than a column per heading. The set is the sidebar's
|
||||||
|
-- to decide and it has already changed twice; a column each would mean a migration
|
||||||
|
-- every time a heading is added, and a row full of booleans nobody reads for the
|
||||||
|
-- ones that were removed. Unknown keys are ignored on the way out, so an old
|
||||||
|
-- client's folded state survives a heading being renamed rather than resetting it.
|
||||||
|
ALTER TABLE users ADD COLUMN sidebar_collapsed TEXT NOT NULL DEFAULT '[]';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Settings that belong to the instance rather than to a person.
|
||||||
|
--
|
||||||
|
-- The first of them is the Gmail OAuth client. It was configuration only —
|
||||||
|
-- `VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` — which on a Rune means editing the
|
||||||
|
-- manifest and recreating the container to change a value you paste once and
|
||||||
|
-- never touch again. The registration itself cannot be avoided (Google issues no
|
||||||
|
-- Gmail access to an unregistered client, and `gmail.readonly` is a restricted
|
||||||
|
-- scope, so a client id shipped inside a public image would be both extractable
|
||||||
|
-- and unusable), but having to redeploy to enter it can.
|
||||||
|
--
|
||||||
|
-- Its own table rather than `user_settings`, for two reasons. An OAuth client is
|
||||||
|
-- the instance's, not one person's — everybody connecting their mailbox uses the
|
||||||
|
-- same registration. And `user_settings` has `user_id` as its whole primary key,
|
||||||
|
-- so it can hold exactly one scope per person and its upsert rewrites the scope
|
||||||
|
-- name on conflict; adding a second scope there would silently overwrite the AI
|
||||||
|
-- provider's key.
|
||||||
|
--
|
||||||
|
-- JSON for the same reason templates are: written whole, read whole, and it would
|
||||||
|
-- otherwise be a migration every time a setting is added.
|
||||||
|
CREATE TABLE instance_settings (
|
||||||
|
scope TEXT PRIMARY KEY,
|
||||||
|
values_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- A group becomes somewhere you can go, rather than only a heading.
|
||||||
|
--
|
||||||
|
-- Until now a project group was a label over some rows in the sidebar: a name, a
|
||||||
|
-- colour, and whether it was folded. That is enough to tidy a list and not enough
|
||||||
|
-- to be the thing people actually mean by it — "Arbejde" is a body of work with
|
||||||
|
-- context of its own: what it is, and the documents that belong to all of it
|
||||||
|
-- rather than to any one project inside it.
|
||||||
|
--
|
||||||
|
-- Two changes. A description on the group, and attachments that can hang on one.
|
||||||
|
--
|
||||||
|
-- The attachments table has to be rebuilt for the second, because its CHECK spells
|
||||||
|
-- out exactly which parent columns may be set, and SQLite cannot alter a CHECK.
|
||||||
|
--
|
||||||
|
-- Foreign keys go off for the length of it — see the marker below and the note in
|
||||||
|
-- `applyMigration`. Not because the drop would cascade: nothing references
|
||||||
|
-- `attachments`, so it takes nothing with it. It is for the check that comes with
|
||||||
|
-- the marker. With foreign keys on, the copy is validated a row at a time against
|
||||||
|
-- a half-rebuilt schema and the first bad row aborts with no idea how many others
|
||||||
|
-- there are; with them off, `PRAGMA foreign_key_check` runs once before the commit
|
||||||
|
-- and names what is actually wrong. This runs against a live database on somebody's
|
||||||
|
-- next restart, and an attachment whose task was already gone would otherwise stop
|
||||||
|
-- the server from starting with one row's worth of explanation.
|
||||||
|
--
|
||||||
|
-- The rule stays "exactly one parent": an attachment belongs to a task, a comment
|
||||||
|
-- or a group, never two. An attachment with two parents is one that gets deleted
|
||||||
|
-- once and stays visible in the other place.
|
||||||
|
--
|
||||||
|
-- verdande:rebuild-tables
|
||||||
|
|
||||||
|
ALTER TABLE project_groups ADD COLUMN description TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE attachments_rebuilt (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||||
|
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
|
||||||
|
group_id TEXT REFERENCES project_groups (id) ON DELETE CASCADE,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
-- Exactly one parent, counted rather than spelled out as a chain of pairwise
|
||||||
|
-- comparisons: with three columns that is three clauses, with four it is six,
|
||||||
|
-- and the version that is wrong looks exactly like the version that is right.
|
||||||
|
CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL) + (group_id IS NOT NULL) = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO attachments_rebuilt
|
||||||
|
(id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at)
|
||||||
|
SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||||
|
FROM attachments;
|
||||||
|
|
||||||
|
DROP TABLE attachments;
|
||||||
|
|
||||||
|
ALTER TABLE attachments_rebuilt RENAME TO attachments;
|
||||||
|
|
||||||
|
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_attachments_group ON attachments (group_id) WHERE group_id IS NOT NULL;
|
||||||
@@ -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);
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
@@ -346,3 +347,43 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
|
|||||||
userID, scope, string(raw), time.Now().Unix())
|
userID, scope, string(raw), time.Now().Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- instance-wide settings -------------------------------------------------------------
|
||||||
|
|
||||||
|
// InstanceSettings reads one scope of the instance's own settings. A missing row
|
||||||
|
// is an empty map, not an error.
|
||||||
|
//
|
||||||
|
// Separate from UserSettings because some configuration is the server's rather
|
||||||
|
// than a person's — the Gmail OAuth client is one registration that everybody
|
||||||
|
// connecting a mailbox goes through, not a preference each of them holds.
|
||||||
|
func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]any, error) {
|
||||||
|
var raw string
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT values_json FROM instance_settings WHERE scope = ?`, scope).Scan(&raw)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
|
||||||
|
raw, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.ExecContext(ctx, `
|
||||||
|
INSERT INTO instance_settings (scope, values_json, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT (scope) DO UPDATE SET
|
||||||
|
values_json = excluded.values_json,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
scope, string(raw), time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Passkey is one credential on one device.
|
||||||
|
//
|
||||||
|
// One row per credential rather than per account: a person has a laptop and a
|
||||||
|
// phone, and losing one must not mean losing the way in. The name is theirs to
|
||||||
|
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
|
||||||
|
// read is a list nobody revokes from.
|
||||||
|
type Passkey struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
CredentialID string
|
||||||
|
PublicKey []byte
|
||||||
|
AAGUID string
|
||||||
|
SignCount uint32
|
||||||
|
Discoverable bool
|
||||||
|
UserVerified bool
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastUsedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
|
||||||
|
if p.ID == "" {
|
||||||
|
p.ID = NewID()
|
||||||
|
}
|
||||||
|
p.CreatedAt = time.Now().UTC()
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||||
|
discoverable, user_verified, name, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
|
||||||
|
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||||
|
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||||
|
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanPasskeys(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyByCredentialID is how a login finds the account, before anybody has said
|
||||||
|
// who they are. The credential id is unique across the instance, which is what
|
||||||
|
// makes that possible.
|
||||||
|
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||||
|
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||||
|
FROM passkeys WHERE credential_id = ?`, credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
list, err := scanPasskeys(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return &list[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchPasskey records a use and the authenticator's counter.
|
||||||
|
//
|
||||||
|
// The counter is stored to be compared. One that goes backwards means the
|
||||||
|
// credential has been cloned, which is the single thing this design cannot
|
||||||
|
// otherwise detect — and not every authenticator keeps one, so zero means "not
|
||||||
|
// offered" rather than "suspicious".
|
||||||
|
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
|
||||||
|
signCount, time.Now().Unix(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
|
||||||
|
// id read in one step and deleted in the next is a window, and the reason somebody
|
||||||
|
// is on this screen at all is usually that they think a device is gone.
|
||||||
|
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPasskeys is what stops somebody removing their last way in when it is the
|
||||||
|
// only one they have.
|
||||||
|
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
|
||||||
|
out := []Passkey{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Passkey
|
||||||
|
var discoverable, verified int
|
||||||
|
var created, used int64
|
||||||
|
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
|
||||||
|
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Discoverable = discoverable == 1
|
||||||
|
p.UserVerified = verified == 1
|
||||||
|
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
if used > 0 {
|
||||||
|
p.LastUsedAt = time.Unix(used, 0).UTC()
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- challenges -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// StoreChallenge keeps the server's side of one ceremony.
|
||||||
|
//
|
||||||
|
// Server-side rather than in a cookie: the whole point of a challenge is that the
|
||||||
|
// server chose it and remembers choosing it. It is deleted when answered and swept
|
||||||
|
// when it expires — a challenge that outlives its ceremony is a replay waiting to
|
||||||
|
// be tried.
|
||||||
|
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
|
||||||
|
id := NewID()
|
||||||
|
now := time.Now()
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeChallenge reads a challenge and removes it in the same transaction.
|
||||||
|
//
|
||||||
|
// Taken rather than read, because a challenge is answerable exactly once. Two
|
||||||
|
// requests racing the same one would otherwise both succeed, which is the whole
|
||||||
|
// shape of a replay.
|
||||||
|
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var owner sql.NullString
|
||||||
|
var expires int64
|
||||||
|
row := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
|
||||||
|
id, purpose)
|
||||||
|
if err := row.Scan(&owner, &session, &expires); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if time.Now().Unix() > expires {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
userID = owner.String
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return userID, session, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
|
||||||
|
// itself; an abandoned one is what this is for.
|
||||||
|
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A ProjectGroup is a heading in the sidebar with projects filed under it.
|
||||||
|
//
|
||||||
|
// Groups belong to one person. They are not shared and never travel with a
|
||||||
|
// project: somebody who has a project shared with them files it where they like,
|
||||||
|
// which is possible precisely because the group is theirs and the membership is
|
||||||
|
// not. That is also why a shared project can never be in a group — see
|
||||||
|
// SetProjectGroup.
|
||||||
|
type ProjectGroup struct {
|
||||||
|
ID string
|
||||||
|
OwnerID string
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
// Description is what the group is, in the owner's words. A heading can carry
|
||||||
|
// a name; a page can carry the sentence that says why these projects are the
|
||||||
|
// same body of work.
|
||||||
|
Description string
|
||||||
|
Collapsed bool
|
||||||
|
SortOrder float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT id, owner_id, name, color, description, collapsed, sort_order, created_at, updated_at
|
||||||
|
FROM project_groups WHERE owner_id = ?
|
||||||
|
ORDER BY sort_order, created_at`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
groups := []ProjectGroup{}
|
||||||
|
for rows.Next() {
|
||||||
|
var g ProjectGroup
|
||||||
|
var collapsed int
|
||||||
|
var created, updated int64
|
||||||
|
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &g.Description, &collapsed,
|
||||||
|
&g.SortOrder, &created, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g.Collapsed = collapsed == 1
|
||||||
|
g.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
g.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
return groups, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjectGroup is scoped to the owner rather than looked up by id alone, so a
|
||||||
|
// handler cannot accidentally hand somebody else's group back by forgetting a
|
||||||
|
// check the query could have made itself.
|
||||||
|
func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*ProjectGroup, error) {
|
||||||
|
var g ProjectGroup
|
||||||
|
var collapsed int
|
||||||
|
var created, updated int64
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, owner_id, name, color, description, collapsed, sort_order, created_at, updated_at
|
||||||
|
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
|
||||||
|
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &g.Description, &collapsed, &g.SortOrder,
|
||||||
|
&created, &updated)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g.Collapsed = collapsed == 1
|
||||||
|
g.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
g.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
return &g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
|
||||||
|
if g.ID == "" {
|
||||||
|
g.ID = NewID()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
g.CreatedAt, g.UpdatedAt = now, now
|
||||||
|
if g.Color == "" {
|
||||||
|
g.Color = DefaultColor
|
||||||
|
}
|
||||||
|
if g.SortOrder == 0 {
|
||||||
|
var max sql.NullFloat64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT max(sort_order) FROM project_groups WHERE owner_id = ?`,
|
||||||
|
g.OwnerID).Scan(&max); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.SortOrder = max.Float64 + 1024
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
g.ID, g.OwnerID, g.Name, g.Color, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectGroupUpdate struct {
|
||||||
|
Name *string
|
||||||
|
Color *string
|
||||||
|
Description *string
|
||||||
|
Collapsed *bool
|
||||||
|
SortOrder *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
|
||||||
|
set, args := buildUpdate(map[string]any{
|
||||||
|
"name": u.Name,
|
||||||
|
"color": u.Color,
|
||||||
|
"description": u.Description,
|
||||||
|
"collapsed": u.Collapsed,
|
||||||
|
"sort_order": u.SortOrder,
|
||||||
|
})
|
||||||
|
if len(set) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set = append(set, "updated_at = ?")
|
||||||
|
args = append(args, time.Now().Unix(), groupID, userID)
|
||||||
|
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE project_groups SET `+joinComma(set)+` WHERE id = ? AND owner_id = ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProjectGroup removes the heading and leaves the projects.
|
||||||
|
//
|
||||||
|
// The projects are ungrouped explicitly rather than left to the foreign key's
|
||||||
|
// ON DELETE SET NULL: the pragma is set per connection, and a schema that quietly
|
||||||
|
// depends on it would keep the rows pointing at a group that no longer exists on
|
||||||
|
// any connection that forgot. Hard deleted rather than soft: a group holds no
|
||||||
|
// work of its own, so there is nothing in it to recover.
|
||||||
|
func (db *DB) DeleteProjectGroup(ctx context.Context, groupID, userID string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`UPDATE projects SET group_id = NULL, updated_at = ? WHERE group_id = ?`,
|
||||||
|
time.Now().Unix(), groupID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderProjectGroups writes the given order, and only for groups the caller
|
||||||
|
// owns — the same whole-list write the projects use, for the same reason: a
|
||||||
|
// handful of rows, one transaction, no precision to run out of.
|
||||||
|
func (db *DB) ReorderProjectGroups(ctx context.Context, userID string, ids []string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE project_groups SET sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND owner_id = ?`,
|
||||||
|
float64(i), now, id, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProjectGroup files a project under a group, or takes it out of one when
|
||||||
|
// groupID is empty.
|
||||||
|
//
|
||||||
|
// Both ends are checked against the caller: the group must be theirs, and so must
|
||||||
|
// the project. Ownership rather than edit rights, and for the same reason
|
||||||
|
// sort_order is owner-scoped — the column lives on the project row, so a member
|
||||||
|
// filing a shared project into a group of their own would move it in the owner's
|
||||||
|
// sidebar as well. Until grouping is per viewer, a shared project stays where its
|
||||||
|
// owner has it.
|
||||||
|
func (db *DB) SetProjectGroup(ctx context.Context, projectID, groupID, userID string) error {
|
||||||
|
var group any
|
||||||
|
if groupID != "" {
|
||||||
|
var exists int
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT 1 FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).Scan(&exists)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
group = groupID
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE projects SET group_id = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND owner_id = ? AND is_inbox = 0 AND deleted_at IS NULL`,
|
||||||
|
group, time.Now().Unix(), projectID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTaskCounts is how much is left in each project of a group.
|
||||||
|
//
|
||||||
|
// One query rather than one per project: the group's page draws a list, and a
|
||||||
|
// count per row would be a query per row.
|
||||||
|
//
|
||||||
|
// Open, not total. "12" beside a project you finished last year is a number that
|
||||||
|
// means nothing; the question a list of projects answers is what is still on it.
|
||||||
|
// Sub-tasks are excluded for the same reason the project view nests them — they
|
||||||
|
// are part of their parent, not another thing to do.
|
||||||
|
func (db *DB) OpenTaskCounts(ctx context.Context, groupID, userID string) (map[string]int, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT p.id, count(t.id)
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN tasks t ON t.project_id = p.id
|
||||||
|
AND t.deleted_at IS NULL AND t.completed_at IS NULL AND t.parent_id IS NULL
|
||||||
|
WHERE p.group_id = ? AND p.owner_id = ? AND p.deleted_at IS NULL
|
||||||
|
GROUP BY p.id`, groupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
counts := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&id, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts[id] = n
|
||||||
|
}
|
||||||
|
return counts, rows.Err()
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ type Project struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
// GroupID is which sidebar group the *asking* user has filed it under, and is
|
||||||
|
// empty for a project shared with them: the column is on the project row, so
|
||||||
|
// the owner's filing is not an answer anybody else asked for.
|
||||||
|
GroupID string
|
||||||
|
|
||||||
// Role is the asking user's standing, filled in by the list and get calls so
|
// Role is the asking user's standing, filled in by the list and get calls so
|
||||||
// the frontend can grey out what they cannot do without a second request.
|
// the frontend can grey out what they cannot do without a second request.
|
||||||
Role Role
|
Role Role
|
||||||
@@ -44,7 +49,8 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
|
|||||||
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||||
p.archived, p.sort_order, p.created_at, p.updated_at,
|
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||||
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
|
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
|
||||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count
|
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count,
|
||||||
|
CASE WHEN p.owner_id = ? THEN p.group_id END AS group_id
|
||||||
FROM projects p
|
FROM projects p
|
||||||
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
||||||
WHERE p.deleted_at IS NULL
|
WHERE p.deleted_at IS NULL
|
||||||
@@ -54,7 +60,7 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
|
|||||||
}
|
}
|
||||||
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
|
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query, userID, userID, userID)
|
rows, err := db.QueryContext(ctx, query, userID, userID, userID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -81,8 +87,9 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
|
|||||||
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||||
p.archived, p.sort_order, p.created_at, p.updated_at,
|
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||||
? AS role,
|
? AS role,
|
||||||
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
|
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1,
|
||||||
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), projectID)
|
CASE WHEN p.owner_id = ? THEN p.group_id END
|
||||||
|
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), userID, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -100,17 +107,18 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
|
|||||||
|
|
||||||
func scanProject(rows *sql.Rows) (Project, error) {
|
func scanProject(rows *sql.Rows) (Project, error) {
|
||||||
var p Project
|
var p Project
|
||||||
var icon sql.NullString
|
var icon, groupID sql.NullString
|
||||||
var isInbox, archived int
|
var isInbox, archived int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
var role string
|
var role string
|
||||||
|
|
||||||
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
|
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
|
||||||
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount)
|
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
p.Icon = icon.String
|
p.Icon = icon.String
|
||||||
|
p.GroupID = groupID.String
|
||||||
p.IsInbox = isInbox == 1
|
p.IsInbox = isInbox == 1
|
||||||
p.Archived = archived == 1
|
p.Archived = archived == 1
|
||||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
@@ -234,6 +242,55 @@ func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrashedProject is a deleted project, with when it went and when it goes for good.
|
||||||
|
type TrashedProject struct {
|
||||||
|
Project
|
||||||
|
DeletedAt time.Time
|
||||||
|
TaskCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrashedProjects returns what the caller owns and has deleted.
|
||||||
|
//
|
||||||
|
// Restoring has been possible since the trash existed, but only if you already
|
||||||
|
// knew the project's id — which is exactly what you no longer have once it has
|
||||||
|
// gone from the interface. A recovery window nobody can reach is not one.
|
||||||
|
//
|
||||||
|
// Owned, not shared: a member of a project somebody else deleted has no business
|
||||||
|
// bringing it back.
|
||||||
|
func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||||
|
p.archived, p.sort_order, p.created_at, p.updated_at, p.deleted_at,
|
||||||
|
(SELECT count(*) FROM tasks t
|
||||||
|
WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count
|
||||||
|
FROM projects p
|
||||||
|
WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL
|
||||||
|
ORDER BY p.deleted_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []TrashedProject{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t TrashedProject
|
||||||
|
var created, updated, deleted int64
|
||||||
|
var icon sql.NullString
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID,
|
||||||
|
&t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted,
|
||||||
|
&t.TaskCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Icon = icon.String
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
t.DeletedAt = time.Unix(deleted, 0).UTC()
|
||||||
|
t.Role = RoleOwner
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreProject brings a project back, along with the tasks that were deleted with
|
// RestoreProject brings a project back, along with the tasks that were deleted with
|
||||||
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
||||||
// and separately, and restoring a project should not undo that too.
|
// and separately, and restoring a project should not undo that too.
|
||||||
@@ -279,15 +336,27 @@ type Member struct {
|
|||||||
// project_members row — ownership is the projects table — so they are unioned in
|
// project_members row — ownership is the projects table — so they are unioned in
|
||||||
// rather than left out of the list of people who can see it.
|
// rather than left out of the list of people who can see it.
|
||||||
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
||||||
|
// The owner first, then everybody else by name.
|
||||||
|
//
|
||||||
|
// The ordering is a column rather than an expression, and the whole compound
|
||||||
|
// select is wrapped: SQLite only accepts ORDER BY terms from the result set
|
||||||
|
// of a UNION, so `ORDER BY role = 'owner' DESC` — which reads perfectly well
|
||||||
|
// — fails at query time with "1st ORDER BY term does not match any column in
|
||||||
|
// the result set". Every call errored, so the share panel had never listed
|
||||||
|
// anybody.
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
|
SELECT id, email, name, avatar_color, role, added_at FROM (
|
||||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role,
|
||||||
WHERE p.id = ?
|
p.created_at AS added_at, 0 AS rank
|
||||||
UNION ALL
|
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
|
WHERE p.id = ?
|
||||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
UNION ALL
|
||||||
WHERE m.project_id = ?
|
SELECT u.id, u.email, u.name, u.avatar_color, m.role,
|
||||||
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
|
m.added_at, 1 AS rank
|
||||||
|
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.project_id = ?
|
||||||
|
)
|
||||||
|
ORDER BY rank, name`, projectID, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -320,6 +389,33 @@ func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMemberRole changes what an existing member may do.
|
||||||
|
//
|
||||||
|
// An UPDATE rather than the upsert AddMember does, and the difference is the
|
||||||
|
// point: this is a PATCH against a membership that exists. Somebody who is not a
|
||||||
|
// member is not silently made one — that is an invite, it belongs to the invite
|
||||||
|
// flow, and doing it here would let a typo in a user id add a stranger to a
|
||||||
|
// project without anybody being asked.
|
||||||
|
//
|
||||||
|
// Owner is refused for the same reason AddMember refuses it: ownership is
|
||||||
|
// transferred, not granted, and two owners is a state with no way to settle a
|
||||||
|
// disagreement about who may remove whom.
|
||||||
|
func (db *DB) SetMemberRole(ctx context.Context, projectID, userID string, role Role) error {
|
||||||
|
if !role.Valid() || role == RoleOwner {
|
||||||
|
return errors.New("store: members may be editor or viewer")
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE project_members SET role = ? WHERE project_id = ? AND user_id = ?`,
|
||||||
|
string(role), projectID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
|
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
// Anything assigned to them is unassigned rather than left pointing at
|
// Anything assigned to them is unassigned rather than left pointing at
|
||||||
@@ -507,3 +603,63 @@ func joinComma(parts []string) string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReorderProjects writes the given order, and only for projects the user owns.
|
||||||
|
//
|
||||||
|
// The whole list at once, rather than the midpoint-between-neighbours dance the
|
||||||
|
// tasks do. A person has a handful of projects, not a thousand, so writing
|
||||||
|
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
|
||||||
|
// failure the task ordering has to respace a section to recover from.
|
||||||
|
//
|
||||||
|
// Scoped to ownership because sort_order lives on the project row rather than
|
||||||
|
// per viewer: a shared project sits where its owner put it, and a member
|
||||||
|
// reordering their sidebar must not rearrange somebody else's. Ids that are not
|
||||||
|
// the caller's are skipped rather than refused, so one shared project in the
|
||||||
|
// list does not fail the whole drag.
|
||||||
|
func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE projects SET sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
|
||||||
|
float64(i), now, id, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionByName resolves a "/name" from quick add within one project.
|
||||||
|
//
|
||||||
|
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||||
|
// belongs to exactly one project, so the name cannot be looked up before that is
|
||||||
|
// decided. Two projects may both have a "Produktion" and they are different
|
||||||
|
// sections; asking without the project would be asking the wrong question.
|
||||||
|
func (db *DB) SectionByName(ctx context.Context, projectID, name string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM sections
|
||||||
|
WHERE project_id = ? AND lower(name) = lower(?) AND deleted_at IS NULL
|
||||||
|
ORDER BY sort_order
|
||||||
|
LIMIT 1`, projectID, name).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionInProject reports whether a section id belongs to the given project.
|
||||||
|
//
|
||||||
|
// Quick add takes a section id from the client — the field at the foot of a
|
||||||
|
// section passes the one it sits in — and a "#project" in the same line can move
|
||||||
|
// the task somewhere else entirely. Without this the id would follow it, and the
|
||||||
|
// task would land in a section belonging to a project it is not in.
|
||||||
|
func (db *DB) SectionInProject(ctx context.Context, sectionID, projectID string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM sections WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||||
|
sectionID, projectID).Scan(&n)
|
||||||
|
return n == 1, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ func (db *DB) DeleteSession(ctx context.Context, sessionID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUserSession ends one session, and only if it belongs to the caller.
|
||||||
|
//
|
||||||
|
// Scoped in the statement rather than checked first: an id read and then deleted
|
||||||
|
// in two steps is a window, and the whole point of this call is that somebody is
|
||||||
|
// using it because they think another person is in their account.
|
||||||
|
func (db *DB) DeleteUserSession(ctx context.Context, sessionID, userID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
||||||
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ type DB struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open prepares the database at path and brings the schema up to date.
|
// dsn is the connection string.
|
||||||
//
|
//
|
||||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||||
// readers and a WebSocket fan-out, so the pragmas below matter more than usual:
|
// readers and a WebSocket fan-out, so the pragmas below matter more than usual:
|
||||||
// WAL lets readers run while a write is in flight, busy_timeout turns the "database
|
// WAL lets readers run while a write is in flight, busy_timeout turns the "database
|
||||||
// is locked" race into a short wait, and foreign_keys is off by default in SQLite
|
// is locked" race into a short wait, and foreign_keys is off by default in SQLite
|
||||||
// and has to be asked for on every connection.
|
// and has to be asked for on every connection.
|
||||||
func Open(path string) (*DB, error) {
|
//
|
||||||
dsn := "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
|
// Its own function so the migration tests can open a database with exactly the
|
||||||
|
// pragmas production runs with.
|
||||||
|
func dsn(path string) string {
|
||||||
|
return "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
|
||||||
"_pragma=journal_mode(WAL)",
|
"_pragma=journal_mode(WAL)",
|
||||||
"_pragma=busy_timeout(5000)",
|
"_pragma=busy_timeout(5000)",
|
||||||
"_pragma=foreign_keys(ON)",
|
"_pragma=foreign_keys(ON)",
|
||||||
@@ -56,8 +59,11 @@ func Open(path string) (*DB, error) {
|
|||||||
// spilling them into the data volume.
|
// spilling them into the data volume.
|
||||||
"_pragma=temp_store(MEMORY)",
|
"_pragma=temp_store(MEMORY)",
|
||||||
}, "&")
|
}, "&")
|
||||||
|
}
|
||||||
|
|
||||||
sqlDB, err := sql.Open("sqlite", dsn)
|
// Open prepares the database at path and brings the schema up to date.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
}
|
}
|
||||||
@@ -91,11 +97,36 @@ func Open(path string) (*DB, error) {
|
|||||||
|
|
||||||
func (db *DB) Path() string { return db.path }
|
func (db *DB) Path() string { return db.path }
|
||||||
|
|
||||||
|
// rebuildMarker opts a migration out of foreign key enforcement for the length of
|
||||||
|
// its transaction.
|
||||||
|
//
|
||||||
|
// SQLite cannot alter a column's foreign key action, so changing one means
|
||||||
|
// rebuilding the table: copy it, drop the original, rename the copy back. With
|
||||||
|
// foreign keys on, the drop is a cascading delete — every row in every table that
|
||||||
|
// references `tasks` would go with it, which is the opposite of what a migration
|
||||||
|
// that exists to *stop* losing rows should do. `PRAGMA foreign_keys` is a no-op
|
||||||
|
// inside a transaction, so it has to be set on the connection before BEGIN; that
|
||||||
|
// is why migrations run on a connection of their own rather than on the pool.
|
||||||
|
//
|
||||||
|
// `PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
|
||||||
|
// dangling reference fails the migration instead of writing it.
|
||||||
|
const rebuildMarker = "-- verdande:rebuild-tables"
|
||||||
|
|
||||||
// migrate applies every embedded migration that has not run yet, in filename order,
|
// migrate applies every embedded migration that has not run yet, in filename order,
|
||||||
// each inside its own transaction. A migration that fails leaves the schema at the
|
// each inside its own transaction. A migration that fails leaves the schema at the
|
||||||
// last version that succeeded rather than half-applied.
|
// last version that succeeded rather than half-applied.
|
||||||
|
//
|
||||||
|
// Everything here runs on one connection: a migration marked with rebuildMarker
|
||||||
|
// needs a pragma to hold across its transaction, and a pragma only applies to the
|
||||||
|
// connection it was issued on.
|
||||||
func (db *DB) migrate(ctx context.Context) error {
|
func (db *DB) migrate(ctx context.Context) error {
|
||||||
if _, err := db.ExecContext(ctx, `
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, `
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
applied_at INTEGER NOT NULL
|
applied_at INTEGER NOT NULL
|
||||||
@@ -104,7 +135,7 @@ func (db *DB) migrate(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applied := map[string]bool{}
|
applied := map[string]bool{}
|
||||||
rows, err := db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
rows, err := conn.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read schema_migrations: %w", err)
|
return fmt.Errorf("read schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -136,27 +167,69 @@ func (db *DB) migrate(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := db.applyMigration(ctx, conn, name, string(body)); err != nil {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return fmt.Errorf("migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
|
||||||
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return fmt.Errorf("record migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) applyMigration(ctx context.Context, conn *sql.Conn, name, body string) error {
|
||||||
|
if strings.Contains(body, rebuildMarker) {
|
||||||
|
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return fmt.Errorf("migration %s: disable foreign keys: %w", name, err)
|
||||||
|
}
|
||||||
|
// Back on whatever happens below: this connection goes back to the pool.
|
||||||
|
defer conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, body); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, rebuildMarker) {
|
||||||
|
if err := foreignKeyCheck(ctx, tx); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreignKeyCheck reports the first row that references something that is not
|
||||||
|
// there. It names the table and the rowid, because a rebuild that drops rows on the
|
||||||
|
// floor is otherwise indistinguishable from one that worked.
|
||||||
|
func foreignKeyCheck(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("foreign key check: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if rows.Next() {
|
||||||
|
var table, parent string
|
||||||
|
var rowid sql.NullInt64
|
||||||
|
var fkid int
|
||||||
|
if err := rows.Scan(&table, &rowid, &parent, &fkid); err != nil {
|
||||||
|
return fmt.Errorf("foreign key check: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("foreign key check: %s row %d references a missing %s",
|
||||||
|
table, rowid.Int64, parent)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Tx runs fn inside a transaction, rolling back on error or panic.
|
// Tx runs fn inside a transaction, rolling back on error or panic.
|
||||||
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// openTest gives each test its own database file. In-memory SQLite would be faster,
|
// openTest gives each test its own database file. In-memory SQLite would be faster,
|
||||||
@@ -410,3 +414,213 @@ func ftsCount(t *testing.T, db *DB, query string) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rebuild in 0008 is the one migration that does not just add: it copies the
|
||||||
|
// tasks table, drops the original and renames the copy back. Every test above this
|
||||||
|
// one starts from an empty database, where a rebuild that loses rows looks exactly
|
||||||
|
// like a rebuild that works — so this one carries data across it.
|
||||||
|
//
|
||||||
|
// What can go wrong, in the order it would: the drop cascades and takes the child
|
||||||
|
// rows with it; the copy misses a column; the FTS index is left keyed on rowids the
|
||||||
|
// rebuilt table no longer hands out; and finally the thing the migration is for,
|
||||||
|
// which is that deleting an account stops deleting other people's work.
|
||||||
|
func TestTheTasksRebuildCarriesTheDataAcross(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "upgrade.db")
|
||||||
|
|
||||||
|
// A database at 0007: the schema as it shipped in v0.10.2.
|
||||||
|
sqlDB, err := sql.Open("sqlite", dsn(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at INTEGER NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := fs.Glob(migrationFS, "migrations/*.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sort.Strings(entries)
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := filepath.Base(entry)
|
||||||
|
if name >= "0008" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
body, err := migrationFS.ReadFile(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, string(body)); err != nil {
|
||||||
|
t.Fatalf("apply %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two people, one project each, and a task in each project written by the
|
||||||
|
// other — which is the shape the migration exists for. Plus a label, a comment
|
||||||
|
// and a sub-task, so the child tables have something to lose.
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`INSERT INTO users (id, email, name, password_hash, avatar_color, is_admin, created_at, updated_at)
|
||||||
|
VALUES ('u1', 'en@example.dk', 'En', 'x', 'graphite', 1, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||||
|
('u2', 'to@example.dk', 'To', 'x', 'graphite', 0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO projects (id, owner_id, name, color, created_at, updated_at)
|
||||||
|
VALUES ('p1', 'u1', 'Ens', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||||
|
('p2', 'u2', 'Tos', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO tasks (id, project_id, content, description, created_by, priority, sort_order, created_at, updated_at)
|
||||||
|
VALUES ('t1', 'p1', 'grøn hæk', 'i ens eget', 'u1', 2, 1.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||||
|
('t2', 'p2', 'skrevet af en anden', '', 'u1', 4, 2.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO tasks (id, project_id, parent_id, content, created_by, sort_order, created_at, updated_at)
|
||||||
|
VALUES ('t3', 'p1', 't1', 'undertask', 'u1', 3.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO labels (id, user_id, name, created_at) VALUES ('l1', 'u1', 'have', ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO task_labels (task_id, label_id) VALUES ('t1', 'l1')`,
|
||||||
|
`INSERT INTO comments (id, task_id, user_id, body, created_at, updated_at)
|
||||||
|
VALUES ('c1', 't1', 'u1', 'en kommentar', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
// 0009 rebuilds activity and 0012 rebuilds attachments, so both need rows
|
||||||
|
// here: a rebuild that drops them looks exactly like one that works.
|
||||||
|
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||||
|
VALUES ('a1', 'p1', 't1', 'u1', 'task.created', '{}', ` + fmt.Sprint(now) + `),
|
||||||
|
('a2', 'p2', NULL, 'u1', 'project.created', '{}', ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO attachments (id, task_id, filename, mime_type, size, path, uploaded_by, created_at)
|
||||||
|
VALUES ('f1', 't1', 'tegning.pdf', 'application/pdf', 1024, 'ab/cd/abcd', 'u1', ` + fmt.Sprint(now) + `)`,
|
||||||
|
`INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at)
|
||||||
|
VALUES ('g1', 'u1', 'Arbejde', 'graphite', 0, 1024, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||||
|
} {
|
||||||
|
if _, err := sqlDB.ExecContext(ctx, stmt); err != nil {
|
||||||
|
t.Fatalf("seed: %v\n%s", err, stmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlDB.Close()
|
||||||
|
|
||||||
|
// Now the upgrade, through the real runner.
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upgrade: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var tasks, labels, comments int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks`).Scan(&tasks); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM task_labels`).Scan(&labels); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM comments`).Scan(&comments); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tasks != 3 || labels != 1 || comments != 1 {
|
||||||
|
t.Fatalf("after the rebuild: %d tasks, %d labels, %d comments — want 3, 1, 1. "+
|
||||||
|
"A drop that cascaded would look exactly like this", tasks, labels, comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0009 and 0012 rebuild activity and attachments by the same machinery. They
|
||||||
|
// run against a live database on the next restart, and the thing that goes
|
||||||
|
// wrong in a rebuild is rows quietly not arriving.
|
||||||
|
var activity, attachments, groups int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM activity`).Scan(&activity); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM attachments`).Scan(&attachments); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM project_groups`).Scan(&groups); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if activity != 2 || attachments != 1 || groups != 1 {
|
||||||
|
t.Fatalf("after 0009 and 0012: %d activity rows, %d attachments, %d groups — want 2, 1, 1",
|
||||||
|
activity, attachments, groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attachment kept its parent and its bytes. A rebuild that lost the task_id
|
||||||
|
// leaves a row nothing can reach, which is worse than losing it outright.
|
||||||
|
var fileParent, filename string
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT task_id, filename FROM attachments WHERE id = 'f1'`).Scan(&fileParent, &filename); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fileParent != "t1" || filename != "tegning.pdf" {
|
||||||
|
t.Errorf("the attachment came across as %q on %q", filename, fileParent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the new columns arrived with their defaults rather than as NULL, which is
|
||||||
|
// what an ALTER that forgot NOT NULL DEFAULT would give.
|
||||||
|
var groupAbout, sidebar string
|
||||||
|
if err := db.QueryRow(`SELECT description FROM project_groups WHERE id = 'g1'`).Scan(&groupAbout); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT sidebar_collapsed FROM users WHERE id = 'u1'`).Scan(&sidebar); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if groupAbout != "" || sidebar != "[]" {
|
||||||
|
t.Errorf("new columns on existing rows: description %q, sidebar_collapsed %q", groupAbout, sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every column came across, including the generated one and the sub-task's
|
||||||
|
// self-reference.
|
||||||
|
var content, description, parent string
|
||||||
|
var priority int
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT content, description, priority FROM tasks WHERE id = 't1'`).
|
||||||
|
Scan(&content, &description, &priority); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if content != "grøn hæk" || description != "i ens eget" || priority != 2 {
|
||||||
|
t.Errorf("t1 came across as %q/%q/%d", content, description, priority)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT parent_id FROM tasks WHERE id = 't3'`).Scan(&parent); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parent != "t1" {
|
||||||
|
t.Errorf("the sub-task lost its parent: %q", parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FTS index was rebuilt against the new rowids. Searching the fold column
|
||||||
|
// is the sharpest check: it is generated, so a copy that skipped it would still
|
||||||
|
// have the row and not the index entry.
|
||||||
|
for _, q := range []string{"grøn", "gron", "haek"} {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks_fts WHERE tasks_fts MATCH ?`, q).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("search %q: %v", q, err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("search for %q found %d rows, want 1 — the index is keyed on rowids "+
|
||||||
|
"the rebuilt table no longer hands out", q, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the point of the whole thing.
|
||||||
|
if err := db.DeleteUser(ctx, "u1"); err != nil {
|
||||||
|
t.Fatalf("delete user: %v", err)
|
||||||
|
}
|
||||||
|
var survived int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE id = 't2'`).Scan(&survived); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if survived != 1 {
|
||||||
|
t.Fatal("a task written in somebody else's project went with the account")
|
||||||
|
}
|
||||||
|
var author sql.NullString
|
||||||
|
if err := db.QueryRow(`SELECT created_by FROM tasks WHERE id = 't2'`).Scan(&author); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if author.Valid {
|
||||||
|
t.Errorf("created_by = %q, want null", author.String)
|
||||||
|
}
|
||||||
|
// Their own project still goes, and everything in it with it.
|
||||||
|
var own int
|
||||||
|
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE project_id = 'p1'`).Scan(&own); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if own != 0 {
|
||||||
|
t.Errorf("%d tasks survived in the deleted account's own project", own)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,11 +29,23 @@ type Task struct {
|
|||||||
AssigneeID string
|
AssigneeID string
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
CompletedBy string
|
CompletedBy string
|
||||||
CreatedBy string
|
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||||
SortOrder float64
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||||
Labels []string
|
CreatedBy string
|
||||||
CreatedAt time.Time
|
SortOrder float64
|
||||||
UpdatedAt time.Time
|
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
||||||
|
// that there is something underneath it, and how much of it is left. Counted
|
||||||
|
// for every task in a list, in the same query — a hundred extra lookups to draw
|
||||||
|
// a badge is not a badge worth having.
|
||||||
|
SubtaskCount int
|
||||||
|
SubtaskDone int
|
||||||
|
// AttachmentCount is the same idea for files. A task with a drawing on it looks
|
||||||
|
// exactly like one without, and the whole reason to attach something is that
|
||||||
|
// somebody should find it.
|
||||||
|
AttachmentCount int
|
||||||
|
Labels []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) Completed() bool { return t.CompletedAt != nil }
|
func (t *Task) Completed() bool { return t.CompletedAt != nil }
|
||||||
@@ -48,8 +60,14 @@ type TaskFilter struct {
|
|||||||
// renders them nested under their parents rather than as separate rows.
|
// renders them nested under their parents rather than as separate rows.
|
||||||
TopLevelOnly bool
|
TopLevelOnly bool
|
||||||
AssigneeID string
|
AssigneeID string
|
||||||
LabelID string
|
// DelegatedBy is the other side of AssigneeID: assigned, but not to this
|
||||||
Priority int
|
// person. A second field rather than a magic value in AssigneeID, because the
|
||||||
|
// two are different questions — "what is on my plate" and "what have I handed
|
||||||
|
// over" — and one string that means an id most of the time and a sentinel the
|
||||||
|
// rest is the kind of field somebody eventually passes a real id to.
|
||||||
|
DelegatedBy string
|
||||||
|
LabelID string
|
||||||
|
Priority int
|
||||||
|
|
||||||
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
|
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
|
||||||
DueBefore string
|
DueBefore string
|
||||||
@@ -116,6 +134,10 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
where = append(where, `t.assignee_id = ?`)
|
where = append(where, `t.assignee_id = ?`)
|
||||||
args = append(args, f.AssigneeID)
|
args = append(args, f.AssigneeID)
|
||||||
}
|
}
|
||||||
|
if f.DelegatedBy != "" {
|
||||||
|
where = append(where, `t.assignee_id IS NOT NULL AND t.assignee_id <> ?`)
|
||||||
|
args = append(args, f.DelegatedBy)
|
||||||
|
}
|
||||||
if f.Priority > 0 {
|
if f.Priority > 0 {
|
||||||
where = append(where, `t.priority = ?`)
|
where = append(where, `t.priority = ?`)
|
||||||
args = append(args, f.Priority)
|
args = append(args, f.Priority)
|
||||||
@@ -136,8 +158,23 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
args = append(args, f.DueFrom)
|
args = append(args, f.DueFrom)
|
||||||
}
|
}
|
||||||
if expr := MatchExpr(f.Search); expr != "" {
|
if expr := MatchExpr(f.Search); expr != "" {
|
||||||
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
|
// Text or a label. A label is how you find a task whose wording you have
|
||||||
args = append(args, expr)
|
// forgotten — "that thing about the accounts" is a search for @regnskab —
|
||||||
|
// and leaving them out made the box look like it had lost the task.
|
||||||
|
//
|
||||||
|
// The label side is a plain lowercase match rather than another FTS index:
|
||||||
|
// labels are short, and their names are typed by the person searching for
|
||||||
|
// them, so there is no spelling to be generous about. The diacritic
|
||||||
|
// folding that matters for task text does not earn a second index here.
|
||||||
|
where = append(where, `(
|
||||||
|
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
||||||
|
OR t.id IN (
|
||||||
|
SELECT tl.task_id FROM task_labels tl
|
||||||
|
JOIN labels l ON l.id = tl.label_id
|
||||||
|
WHERE lower(l.name) LIKE ?
|
||||||
|
)
|
||||||
|
)`)
|
||||||
|
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
||||||
}
|
}
|
||||||
if f.FilterSQL != "" {
|
if f.FilterSQL != "" {
|
||||||
where = append(where, "("+f.FilterSQL+")")
|
where = append(where, "("+f.FilterSQL+")")
|
||||||
@@ -148,11 +185,11 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
t.created_by, t.sort_order, t.created_at, t.updated_at
|
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN projects p ON p.id = t.project_id
|
JOIN projects p ON p.id = t.project_id
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at`
|
ORDER BY ` + order(f)
|
||||||
|
|
||||||
if f.Limit > 0 {
|
if f.Limit > 0 {
|
||||||
query += ` LIMIT ?`
|
query += ` LIMIT ?`
|
||||||
@@ -239,11 +276,11 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, project_id, section_id, parent_id, content, description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
priority, due_date, due_datetime, due_timezone, duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
recurrence_rule, assignee_id, completed_at, completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
created_by, sort_order, created_at, updated_at
|
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
||||||
FROM tasks WHERE id = ? AND deleted_at IS NULL`, taskID)
|
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -292,9 +329,42 @@ func (db *DB) taskLabels(ctx context.Context, taskID string) ([]string, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskCounts are what a row can say about itself without being opened.
|
||||||
|
//
|
||||||
|
// Correlated subqueries rather than a second round of lookups: a list of fifty
|
||||||
|
// tasks would otherwise be a hundred more queries, and the whole point is that a
|
||||||
|
// row shows this without anybody asking for it. `idx_tasks_parent` covers the
|
||||||
|
// first two; the attachment count is a small table with a task_id on it.
|
||||||
|
//
|
||||||
|
// Sub-tasks are counted whether they are open or closed, and the finished ones
|
||||||
|
// again separately, because "3" and "1 of 3" are different sentences and only the
|
||||||
|
// second one says whether there is anything left to do.
|
||||||
|
const taskCounts = `
|
||||||
|
(SELECT count(*) FROM tasks c
|
||||||
|
WHERE c.parent_id = t.id AND c.deleted_at IS NULL),
|
||||||
|
(SELECT count(*) FROM tasks c
|
||||||
|
WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL),
|
||||||
|
(SELECT count(*) FROM attachments a WHERE a.task_id = t.id)`
|
||||||
|
|
||||||
|
// order is how a list of tasks is sorted, which depends on what the list is for.
|
||||||
|
//
|
||||||
|
// A plan is read by when it is due and how urgent it is. A record of what has been
|
||||||
|
// finished is read backwards from now — "what did I just do" and "what did I close
|
||||||
|
// by mistake" are both questions about the most recent thing, and neither is
|
||||||
|
// answered by a due date the task no longer has.
|
||||||
|
func order(f TaskFilter) string {
|
||||||
|
if f.CompletedOnly {
|
||||||
|
return "t.completed_at DESC, t.id DESC"
|
||||||
|
}
|
||||||
|
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||||
|
}
|
||||||
|
|
||||||
func scanTask(rows *sql.Rows) (Task, error) {
|
func scanTask(rows *sql.Rows) (Task, error) {
|
||||||
var t Task
|
var t Task
|
||||||
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
||||||
|
// createdBy is null on a task whose author's account has been deleted. The work
|
||||||
|
// stays; it loses its author.
|
||||||
|
var createdBy sql.NullString
|
||||||
var dueDatetime, completedAt sql.NullInt64
|
var dueDatetime, completedAt sql.NullInt64
|
||||||
var duration sql.NullInt64
|
var duration sql.NullInt64
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
@@ -302,13 +372,15 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||||
&recurrence, &assignee, &completedAt, &completedBy,
|
&recurrence, &assignee, &completedAt, &completedBy,
|
||||||
&t.CreatedBy, &t.SortOrder, &created, &updated)
|
&createdBy, &t.SortOrder, &created, &updated,
|
||||||
|
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return t, err
|
return t, err
|
||||||
}
|
}
|
||||||
|
|
||||||
t.SectionID = sectionID.String
|
t.SectionID = sectionID.String
|
||||||
t.ParentID = parentID.String
|
t.ParentID = parentID.String
|
||||||
|
t.CreatedBy = createdBy.String
|
||||||
t.DueDate = dueDate.String
|
t.DueDate = dueDate.String
|
||||||
t.DueTimezone = dueTZ.String
|
t.DueTimezone = dueTZ.String
|
||||||
t.RecurrenceRule = recurrence.String
|
t.RecurrenceRule = recurrence.String
|
||||||
@@ -359,7 +431,7 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
|
|||||||
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
||||||
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
||||||
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
||||||
nullString(t.RecurrenceRule), nullString(t.AssigneeID), t.CreatedBy,
|
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
||||||
t.SortOrder, now.Unix(), now.Unix())
|
t.SortOrder, now.Unix(), now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,8 +29,15 @@ type User struct {
|
|||||||
Timezone string
|
Timezone string
|
||||||
Locale string
|
Locale string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
CreatedAt time.Time
|
// SidebarCollapsed is the sidebar headings this person has folded away, by
|
||||||
UpdatedAt time.Time
|
// key. On the account rather than in localStorage, for the same reason a
|
||||||
|
// project group carries `collapsed` on its row: folding a heading is a
|
||||||
|
// statement about the work — "I am not in Etiketter at the moment" — and that
|
||||||
|
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
||||||
|
// opposite case and is stored per browser.
|
||||||
|
SidebarCollapsed []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
||||||
@@ -96,7 +104,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||||
avatar_color, timezone, locale, is_admin, created_at, updated_at`
|
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
|
||||||
|
|
||||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
@@ -104,9 +112,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
|||||||
var totpEnabled, isAdmin int
|
var totpEnabled, isAdmin int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
|
|
||||||
|
var collapsed string
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &created, &updated)
|
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -117,11 +127,241 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
|||||||
u.TOTPSecret = secret.String
|
u.TOTPSecret = secret.String
|
||||||
u.TOTPEnabled = totpEnabled == 1
|
u.TOTPEnabled = totpEnabled == 1
|
||||||
u.IsAdmin = isAdmin == 1
|
u.IsAdmin = isAdmin == 1
|
||||||
|
// A malformed value is read as "nothing folded" rather than as an error: the
|
||||||
|
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
||||||
|
u.SidebarCollapsed = []string{}
|
||||||
|
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSidebarCollapsed records which sidebar headings are folded.
|
||||||
|
//
|
||||||
|
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
||||||
|
// a save button and validates three fields together, and this is a toggle that
|
||||||
|
// writes on every click. Sharing the path would mean a fold sending a name and a
|
||||||
|
// timezone it was never asked to change.
|
||||||
|
func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections []string) error {
|
||||||
|
if sections == nil {
|
||||||
|
sections = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(sections)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET sidebar_collapsed = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
string(raw), time.Now().Unix(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Person is the public half of a user: what somebody else is allowed to see when
|
||||||
|
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||||
|
type Person struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
AvatarColor string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeopleByIDs looks up several users at once.
|
||||||
|
//
|
||||||
|
// One query rather than a loop of UserByID, because the caller is resolving the
|
||||||
|
// assignees of a list of tasks and a per-row lookup is a query per row. Unknown
|
||||||
|
// ids are simply absent from the result; a user who has been deleted is not an
|
||||||
|
// error for a view that is only trying to put a name on a row.
|
||||||
|
func (db *DB) PeopleByIDs(ctx context.Context, ids []string) (map[string]Person, error) {
|
||||||
|
people := map[string]Person{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return people, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT id, name, avatar_color FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var p Person
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
people[p.ID] = p
|
||||||
|
}
|
||||||
|
return people, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSummary is a user as the administrator's list shows them: the account, plus
|
||||||
|
// the two numbers that say what deleting it would take with it and the one that
|
||||||
|
// says whether it is in use at all.
|
||||||
|
type UserSummary struct {
|
||||||
|
User
|
||||||
|
// LastSeenAt is the most recent request from any of their sessions, zero if
|
||||||
|
// they have never signed in — which is what an invite that was accepted and
|
||||||
|
// then forgotten looks like.
|
||||||
|
LastSeenAt time.Time
|
||||||
|
// ProjectCount excludes the Inbox: every account has one, so counting it would
|
||||||
|
// make "1 project" mean "nothing".
|
||||||
|
ProjectCount int
|
||||||
|
// TaskCount is everything a delete would destroy: the tasks in the projects
|
||||||
|
// they own, which go with those projects. Tasks they merely *wrote* in
|
||||||
|
// somebody else's project are not counted, because they no longer go —
|
||||||
|
// `created_by` is ON DELETE SET NULL, so that work stays and loses its author.
|
||||||
|
TaskCount int
|
||||||
|
// AuthoredElsewhere is what a delete leaves behind unattributed: tasks they
|
||||||
|
// wrote in projects other people own. Separate from TaskCount because it is a
|
||||||
|
// different sentence — "this much disappears" and "this much stays without a
|
||||||
|
// name on it" — and running them together was what made the old number sound
|
||||||
|
// like a threat it no longer is.
|
||||||
|
AuthoredElsewhere int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns every account on the instance. Administrators only — this is
|
||||||
|
// the whole membership of the server, which is not a list anybody else is owed.
|
||||||
|
func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT u.id, u.email, u.name, u.avatar_color, u.is_admin, u.created_at,
|
||||||
|
COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.user_id = u.id), 0),
|
||||||
|
(SELECT count(*) FROM projects p
|
||||||
|
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
|
||||||
|
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
||||||
|
WHERE t.deleted_at IS NULL AND p.owner_id = u.id),
|
||||||
|
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
||||||
|
WHERE t.deleted_at IS NULL AND t.created_by = u.id AND p.owner_id <> u.id)
|
||||||
|
FROM users u
|
||||||
|
ORDER BY u.is_admin DESC, u.name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []UserSummary{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s UserSummary
|
||||||
|
var isAdmin int
|
||||||
|
var created, lastSeen int64
|
||||||
|
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
|
||||||
|
&lastSeen, &s.ProjectCount, &s.TaskCount, &s.AuthoredElsewhere); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.IsAdmin = isAdmin == 1
|
||||||
|
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
if lastSeen > 0 {
|
||||||
|
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAdmins is what stops the last administrator from being deleted or demoted.
|
||||||
|
// An instance with no administrator has no way back: there is no console, and the
|
||||||
|
// setup route refuses to run once an account exists.
|
||||||
|
func (db *DB) CountAdmins(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE is_admin = 1`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetUserAdmin(ctx context.Context, userID string, admin bool) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET is_admin = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
boolToInt(admin), time.Now().Unix(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes an account and everything that hangs off it.
|
||||||
|
//
|
||||||
|
// A hard delete, and the only one in this database. Every other delete here is a
|
||||||
|
// `deleted_at` with a trash behind it; this one relies on the foreign keys and
|
||||||
|
// there is no way back — which is why the handler sends the counts to the
|
||||||
|
// interface first.
|
||||||
|
//
|
||||||
|
// What goes: `projects.owner_id` cascades, so their projects go and every task in
|
||||||
|
// them goes too. That is the whole of it, and the handler sends the count to the
|
||||||
|
// interface first.
|
||||||
|
//
|
||||||
|
// What stays: everything they wrote in somebody else's project. `tasks.created_by`
|
||||||
|
// used to cascade as well, which meant retiring a colleague's account removed
|
||||||
|
// their contributions from shared work — see migration 0008, which rebuilt the
|
||||||
|
// table to make it ON DELETE SET NULL. The task survives and loses its author,
|
||||||
|
// which is the honest record of what happened.
|
||||||
|
//
|
||||||
|
// Tasks merely *assigned* to them are unassigned by `assignee_id ON DELETE SET
|
||||||
|
// NULL`, and completions by the same rule on `completed_by`.
|
||||||
|
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPeople returns everybody the caller shares at least one project with, plus
|
||||||
|
// the caller.
|
||||||
|
//
|
||||||
|
// The set the interface needs to put a name on an `assignee_id` — in a task row,
|
||||||
|
// in a filter, anywhere a person appears next to work. Fetched once rather than
|
||||||
|
// looked up per row, and deliberately *not* the instance's user list: that is the
|
||||||
|
// administrator's page, and a task list has no business enumerating everybody with
|
||||||
|
// an account here.
|
||||||
|
//
|
||||||
|
// Themselves included, because a client comparing "is this me" against the same
|
||||||
|
// list it draws from has one source rather than two.
|
||||||
|
func (db *DB) ListPeople(ctx context.Context, userID string) ([]Person, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT u.id, u.name, u.avatar_color
|
||||||
|
FROM users u
|
||||||
|
WHERE u.id = ?
|
||||||
|
-- the owners of projects the caller is a member of
|
||||||
|
OR u.id IN (SELECT p.owner_id FROM projects p
|
||||||
|
JOIN project_members m ON m.project_id = p.id
|
||||||
|
WHERE m.user_id = ? AND p.deleted_at IS NULL)
|
||||||
|
-- and the members of every project the caller can see
|
||||||
|
OR u.id IN (SELECT m2.user_id FROM project_members m2
|
||||||
|
JOIN projects p2 ON p2.id = m2.project_id
|
||||||
|
WHERE p2.deleted_at IS NULL
|
||||||
|
AND (p2.owner_id = ?
|
||||||
|
OR EXISTS (SELECT 1 FROM project_members m3
|
||||||
|
WHERE m3.project_id = p2.id AND m3.user_id = ?)))
|
||||||
|
ORDER BY u.name`, userID, userID, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
people := []Person{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Person
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
people = append(people, p)
|
||||||
|
}
|
||||||
|
return people, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// UserCount is what decides whether the instance still needs its first admin.
|
// UserCount is what decides whether the instance still needs its first admin.
|
||||||
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
|
|||||||