Compare commits
@@ -79,12 +79,18 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# Chromium only. A smoke test exists to catch a build that does not work at
|
||||
# all; running it three times in three engines finds the same breakage three
|
||||
# times and costs three times as much.
|
||||
- name: Install Chromium
|
||||
# Chromium for the suite, WebKit for one test.
|
||||
#
|
||||
# A smoke test exists to catch a build that does not work at all, and running
|
||||
# the whole thing in three engines finds the same breakage three times for
|
||||
# three times the cost. Focus is the exception, and it took a real report to
|
||||
# learn it: Safari ignores `autofocus` on anything inserted after the page has
|
||||
# loaded, so every inline form in the app opened with no cursor in it. WebKit
|
||||
# here is the same engine Safari is, and `webkit-forms` in the Playwright
|
||||
# config greps a single test out of the suite to run in it.
|
||||
- name: Install browsers
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||
# playwright.config.js. There is nothing to build here first.
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
|
||||
# Frontend
|
||||
node_modules/
|
||||
# iCloud syncs ~/Documents, and it evicts what it thinks is cold — which for a
|
||||
# dependency tree is nearly all of it. An evicted file blocks on read until it is
|
||||
# downloaded again, and a download that stalls hangs the build for good: 0 % CPU,
|
||||
# no output, forever. macOS never syncs anything ending in .nosync, so the tree
|
||||
# lives there and `node_modules` is a symlink to it. Redo it after a fresh clone:
|
||||
#
|
||||
# mv node_modules node_modules.nosync && ln -s node_modules.nosync node_modules
|
||||
#
|
||||
# The real fix is a repo path outside a synced folder.
|
||||
node_modules.nosync/
|
||||
/web/.svelte-kit/
|
||||
/web/build/
|
||||
|
||||
@@ -50,3 +60,12 @@ node_modules/
|
||||
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
|
||||
# it right back.
|
||||
/shots
|
||||
|
||||
# The working handover. It names the panel, the server id and the production
|
||||
# address — a map of one person's setup, which does not belong in a public repo.
|
||||
# Kept on disk because it is how a session hands over to the next one.
|
||||
HANDOFF.md
|
||||
NOTES-PLAN.md
|
||||
|
||||
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -5,7 +5,17 @@
|
||||
# manager and no libc — the attack surface of the running container is the binary.
|
||||
|
||||
# --- 1. the web interface ----------------------------------------------------
|
||||
FROM node:22-alpine AS web
|
||||
#
|
||||
# Pinned to the *build* platform, not the target. What this stage produces is
|
||||
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
|
||||
# on — so building it once and copying it into both architectures is not an
|
||||
# optimisation, it is the correct thing.
|
||||
#
|
||||
# Without the pin, buildx runs this stage once per target, which means running
|
||||
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
|
||||
# instruction the emulator does not implement, roughly one release in three. It
|
||||
# looks like a broken build and is a broken emulator.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
|
||||
WORKDIR /src
|
||||
COPY web/ ./
|
||||
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
|
||||
fi
|
||||
|
||||
# --- 2. the binary -----------------------------------------------------------
|
||||
FROM golang:1.26-alpine AS build
|
||||
#
|
||||
# Also pinned to the build platform, and cross-compiled instead. Go does that
|
||||
# natively and CGO is already off, so there is nothing to emulate — where
|
||||
# building *in* the target architecture means running the whole Go toolchain
|
||||
# under QEMU for one of the two.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -37,11 +52,13 @@ COPY . .
|
||||
COPY --from=web /src/build ./cmd/verdande/webbuild
|
||||
|
||||
ARG VERSION=dev
|
||||
# Supplied by buildx per target. The default keeps a plain `docker build` working.
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
# CGO off is what makes this binary static, and it is only possible because the
|
||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-tags embedweb \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -35,6 +35,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
||||
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||
views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
@@ -65,6 +70,9 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
|
||||
| Today, Upcoming, search, activity log | Done, tested |
|
||||
| Waiting on others: what you have delegated, by person | Done, tested |
|
||||
| Assignee shown on every task that is somebody else's | Done, smoke-tested |
|
||||
| Activity log, visible per project | Done, smoke-tested |
|
||||
| Change a member's role without removing them | Done, tested |
|
||||
| Sharing: invites, roles, members | Done, tested |
|
||||
| Live sync over WebSocket | Done |
|
||||
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
|
||||
@@ -72,6 +80,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
||||
| Device list: where you are signed in, and signing one out | Done, tested |
|
||||
| User administration: invite to the instance, promote, remove | Done, tested |
|
||||
| Server errors kept in the database, readable after a restart | Done, tested |
|
||||
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
|
||||
| Invite and password-reset links that open the right page | Done, smoke-tested |
|
||||
| Drag to reorder, in both the list and the board | Done |
|
||||
| Drag a task to another project or another day | Done, smoke-tested |
|
||||
@@ -85,21 +95,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Project templates | Done, tested |
|
||||
| Nightly backups with rotation | Done, tested |
|
||||
| Comments, attachments, notifications, Web Push | Built |
|
||||
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
|
||||
| Notes in and out: a zip of Markdown, both directions | Done, tested |
|
||||
| Todoist CSV import and export, account export | Done, tested |
|
||||
| MCP server for Claude | Done, tested |
|
||||
| CalDAV server (two-way VTODO) | Done, tested |
|
||||
| Mail-to-task | Done, tested |
|
||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||
| Update notice for administrators | Done, tested |
|
||||
| Personal API tokens | Done, tested |
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
|
||||
**Not finished:** Web Push, the AI providers and the Gmail API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
not against live services. Everything else has an interface: opening a task shows
|
||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
||||
API calls are implemented against their specifications and have only ever been
|
||||
exercised there, not against live services. Everything else has an interface: opening a task shows
|
||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||
templates.
|
||||
@@ -111,10 +124,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
| Board view — one column per section. | The light theme. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
|
||||
|  |  |
|
||||
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
|
||||
|  |  |
|
||||
| Five themes and four looks: how bright, and how it reads. | 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 and a second one to share with, fills them
|
||||
with demo data and photographs fourteen 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
|
||||
|
||||
@@ -185,6 +208,9 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
||||
|
||||
## What lives in `/data`
|
||||
|
||||
@@ -10,13 +10,28 @@ reply within a week.
|
||||
|
||||
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
|
||||
be raised later without locking anyone out.
|
||||
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
|
||||
stored only as a SHA-256 hash. A database dump contains nothing that can be
|
||||
presented.
|
||||
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
|
||||
database dump contains nothing that can be presented.
|
||||
- **The calendar feed address and the mail-to-task address are not hashed.** They
|
||||
are looked up by their exact value, and they are shown in the interface more
|
||||
than once, which a hash cannot do. Anybody holding a backup file therefore holds
|
||||
those two URLs: the feed reads that person's tasks without a login, and the mail
|
||||
address files tasks into their inbox. Rotating either from **Settings →
|
||||
Integrations** invalidates the old one. This is written down rather than fixed
|
||||
because fixing it means the address can only ever be shown once, which is a
|
||||
decision about how the feature works and not only about how it is stored.
|
||||
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
|
||||
AES-GCM under a key kept outside the database — see `internal/secret`. That is
|
||||
what makes a stray copy of the database less than a stray copy of everything:
|
||||
the backup does not carry the key.
|
||||
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
|
||||
- **Uploads** are stored content-addressed by hash and always served as
|
||||
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
|
||||
on this origin.
|
||||
- **Uploads** are stored content-addressed by hash and served as
|
||||
`application/octet-stream` with `nosniff` — with one exception, added so that a
|
||||
note can show the pictures in it: a short allowlist of raster image types is
|
||||
served inline as its own type. The list is named one format at a time rather
|
||||
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
|
||||
through, and an SVG is a document that can run script. `nosniff` stays, so the
|
||||
browser must read the file as that image or not at all.
|
||||
- **The filter language** compiles to parameterised SQL. No typed text is ever
|
||||
concatenated into a statement.
|
||||
- **Permission checks** are route middleware, and anything you may not see answers
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/httpapi"
|
||||
"github.com/kristianwind/verdande/internal/jobs"
|
||||
"github.com/kristianwind/verdande/internal/secret"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,15 @@ func run() error {
|
||||
defer db.Close()
|
||||
log.Info("database ready", "path", db.Path())
|
||||
|
||||
// Before anything reads a setting. The key lives outside the database on
|
||||
// purpose: backups can be downloaded through the interface, so a token stored
|
||||
// in the clear leaves with every copy anyone takes.
|
||||
box, err := secret.Open(cfg.SecretKey, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db.UseSecrets(box)
|
||||
|
||||
httpapi.Version = version
|
||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||
|
||||
@@ -64,6 +74,10 @@ func run() error {
|
||||
defer stopJobs()
|
||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Calendar and CalDAV
|
||||
|
||||
Two ways to get your tasks into a calendar, and they are not the same thing.
|
||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
||||
verdande, and none of the three is the same thing.
|
||||
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|
||||
|---|---|---|
|
||||
| Direction | Read only | Two-way |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
|
||||
| You can tick things off | No | Yes |
|
||||
| Setup | Paste a URL | Username and token |
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
||||
|---|---|---|---|
|
||||
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||
| You can tick things off | No | Yes | No — an event is not a task |
|
||||
| Setup | Paste a URL | Username and token | Connect, then pick calendars |
|
||||
|
||||
## Subscribing to a feed
|
||||
|
||||
@@ -39,6 +40,42 @@ most people actually keep.
|
||||
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
|
||||
immediately.
|
||||
|
||||
## Showing a Google calendar in verdande
|
||||
|
||||
The two sections above send your tasks *out*. This is the other direction: your
|
||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
||||
already have in it".
|
||||
|
||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
||||
own colour for each one, beside your own tasks with due dates.
|
||||
|
||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
||||
|
||||
!!! warning "Read only"
|
||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
||||
can be changed. Your own tasks in the same grid still drag to another day as
|
||||
they always have.
|
||||
|
||||
Writing would need the `calendar.events` scope and an answer to what should
|
||||
happen when both sides moved the same meeting — which is a synchronisation
|
||||
model rather than a scope, and it is deliberately not built yet.
|
||||
|
||||
!!! note "The Internal client applies here too"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace, calendars included. A personal
|
||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
||||
this feature.
|
||||
|
||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
||||
says so rather than showing an empty month.
|
||||
|
||||
## Connecting a CalDAV client
|
||||
|
||||
This is the one where ticking something off in Apple Reminders ticks it off here.
|
||||
|
||||
@@ -39,12 +39,24 @@ Any Go duration: `720h`, `30m`, `45s`.
|
||||
yourself, password resets are written to the log, and reminders arrive in the
|
||||
app and as Web Push rather than by email.
|
||||
|
||||
## Gmail
|
||||
## Google
|
||||
|
||||
Only needed if anyone will connect a mailbox. Register an OAuth client in Google
|
||||
Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with
|
||||
the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API,
|
||||
and add the `gmail.readonly` scope.
|
||||
Only needed if anyone will connect a Gmail mailbox or a Google calendar. One
|
||||
registration covers both: register an OAuth client in Google Cloud
|
||||
(**APIs & Services → Credentials → OAuth client ID → Web application**) and add
|
||||
**both** redirect URIs:
|
||||
|
||||
```
|
||||
https://your-address/oauth/gmail/callback
|
||||
https://your-address/oauth/calendar/callback
|
||||
```
|
||||
|
||||
Then enable the APIs and scopes for whichever you want:
|
||||
|
||||
| For | Enable | Scope |
|
||||
|---|---|---|
|
||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||
|
||||
| Variable | What it does |
|
||||
|---|---|
|
||||
@@ -52,14 +64,67 @@ and add the `gmail.readonly` scope.
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
|
||||
|
||||
One registration serves every user on the instance; each person authorises their
|
||||
own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
||||
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
|
||||
own mailbox from **Settings → Integrations → Gmail** and their own calendar from
|
||||
**Settings → Integrations → Google Calendar**. Both redirect URIs are derived from
|
||||
`VERDANDE_BASE_URL`, so they cannot drift out of step with what you registered.
|
||||
|
||||
!!! note "Two connections, two grants"
|
||||
Connecting a calendar does not disturb a working Gmail connection, and
|
||||
disconnecting one does not take the other with it. Google issues a refresh
|
||||
token per authorisation and keeps up to a hundred of them live per account,
|
||||
each carrying only the scopes it was granted with.
|
||||
|
||||
## Secrets
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_SECRET_KEY` | — | Encrypts the mail tokens and mailbox passwords in the database. 32 bytes of base64. |
|
||||
|
||||
Mail tokens and [mailbox](mailboxes.md) passwords are encrypted at rest, and the
|
||||
key does not live in the database. That is the whole point: **backups can be
|
||||
downloaded through the interface**, so a copy that ends up somewhere it should
|
||||
not would otherwise open your mail.
|
||||
|
||||
Leave the variable empty and verdande writes a key to `secret.key` in the data
|
||||
directory on first start, mode `0600`. Backups are a `VACUUM INTO` of the
|
||||
database alone, so the key file is not in them.
|
||||
|
||||
Generate one yourself if you would rather keep it out of the volume entirely:
|
||||
|
||||
```
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
!!! warning "Lose the key and the mailboxes must be reconnected"
|
||||
Nothing else is lost — tasks, projects, files and history are not encrypted —
|
||||
but a token that cannot be decrypted cannot be used, and there is no way back
|
||||
from a key that is gone. If you restore a database somewhere new, bring
|
||||
`secret.key` with it, or set `VERDANDE_SECRET_KEY` to the same value.
|
||||
|
||||
A database written before there was a key is read as it stands and encrypted the
|
||||
next time each value is written, so turning this on costs nobody a reconnection.
|
||||
|
||||
## Update checks
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
|
||||
All three together let **Settings → Notifications** restart this instance,
|
||||
which is also how it picks up a new version: a container cannot replace its own
|
||||
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
||||
the panel for a *scheduled* restart rather than an immediate one — an immediate
|
||||
one is carried out while the request is still open, and stopping the container
|
||||
kills the very process waiting for the answer, so the panel never gets to the
|
||||
half that starts it again. Any one of the
|
||||
three alone does nothing, and the page names the ones that are missing.
|
||||
|
||||
The token is a panel credential with control over that server. Anybody who can
|
||||
read this environment can restart it — which is true of anybody with the host
|
||||
anyway — and it is never sent to a browser.
|
||||
|
||||
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
||||
at most every six hours and sends nothing about your instance — no identifier, no
|
||||
|
||||
@@ -22,6 +22,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
||||
and a task shows what has been written about it.
|
||||
- **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
|
||||
@@ -31,6 +34,33 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
|
||||
## What it looks like
|
||||
|
||||

|
||||
|
||||
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
|
||||
dragged from one day onto another.*
|
||||
|
||||

|
||||
|
||||
*The same project as a list, where sections read as bands. As a board they are
|
||||
columns.*
|
||||
|
||||

|
||||
|
||||
*What you have handed to somebody else, grouped by whoever has it.*
|
||||
|
||||

|
||||
|
||||
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||
[mail-to-task address](mail.md) are connected.*
|
||||
|
||||

|
||||
|
||||
*Inviting somebody to the instance. There is no open registration — everybody
|
||||
arrives through a link, and picks their own password. See
|
||||
[signing in](signing-in.md).*
|
||||
|
||||
## Getting it running
|
||||
|
||||
verdande is built to be deployed as a **Rune** in
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Mailboxes
|
||||
|
||||
Star a mail and it becomes a task.
|
||||
|
||||
This is the other direction from [mail to task](mail.md): instead of forwarding
|
||||
something, Verdande reads a mailbox you connect and makes a task out of anything
|
||||
you have flagged. Nothing else in the mailbox is touched, and nothing is ever
|
||||
sent.
|
||||
|
||||
Each mailbox belongs to the person who connected it. Two people on one instance
|
||||
connect their own and see only their own — there is no shared inbox and no
|
||||
administrator who can read yours.
|
||||
|
||||
## Gmail
|
||||
|
||||
**Settings → Integrations → Gmail → Connect.**
|
||||
|
||||
Gmail needs an OAuth client, which is the instance's registration with Google
|
||||
rather than anybody's account — see [Configuration](configuration.md#google).
|
||||
Once it is set up, each person connects their own mailbox through it.
|
||||
|
||||
Choose what makes a task:
|
||||
|
||||
| Trigger | What it reads |
|
||||
|---|---|
|
||||
| Starred | Anything you have starred |
|
||||
| Label | Anything carrying a label you name |
|
||||
|
||||
!!! note "The app stays Internal if your client is Internal"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace. A personal `@gmail.com` address will be
|
||||
refused with `org_internal`, and that is the client working as configured.
|
||||
Switching to *External* means refresh tokens that expire every seven days
|
||||
until Google has verified the app — for one personal account, forwarding to
|
||||
your [mail-to-task address](mail.md) is the shorter road.
|
||||
|
||||
## iCloud, Fastmail and anything else over IMAP
|
||||
|
||||
**Settings → Integrations → Mailboxes → Add a mailbox.**
|
||||
|
||||
You need three things:
|
||||
|
||||
| Field | Example |
|
||||
|---|---|
|
||||
| IMAP server | `imap.mail.me.com:993` |
|
||||
| Username | your address |
|
||||
| App password | not your ordinary password — see below |
|
||||
|
||||
The connection is tested before it is saved. A mailbox that cannot be read is
|
||||
refused while you are still looking at the form, rather than accepted and then
|
||||
failing quietly every ten minutes.
|
||||
|
||||
Flag a message — the flag, not unread — and it becomes a task on the next sweep.
|
||||
Unread is a state you are still using yourself, and a sync that consumed it would
|
||||
be taking something away.
|
||||
|
||||
### App passwords
|
||||
|
||||
Every host worth naming issues a separate password for applications, which you
|
||||
can withdraw on its own without changing the one you sign in with. Use that.
|
||||
|
||||
- **iCloud** — [appleid.apple.com](https://appleid.apple.com) → Sign-In and
|
||||
Security → App-Specific Passwords
|
||||
- **Fastmail** — Settings → Privacy & Security → Integrations → New app password
|
||||
|
||||
The password is [encrypted at rest](configuration.md#secrets), and it is never
|
||||
returned by the API once saved.
|
||||
|
||||
### Office 365 does not work here
|
||||
|
||||
Microsoft has closed IMAP to passwords; it wants its own OAuth flow. There is no
|
||||
app password that will get you in, and a mailbox pointed at `outlook.office365.com`
|
||||
will be refused. Forward to your [mail-to-task address](mail.md) instead.
|
||||
|
||||
## How often
|
||||
|
||||
Every ten minutes, in the background. **Fetch now** does it immediately, which is
|
||||
for when you have just connected something and want to see it work.
|
||||
|
||||
A run reads at most twenty-five messages, so a mailbox with years of flagged mail
|
||||
in it fills up over several sweeps rather than in one.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
**Disconnect** on the mailbox. The tasks it made stay — they are your work now,
|
||||
not the mailbox's. Nothing is read after that, and the stored credential is
|
||||
deleted rather than merely disabled.
|
||||
@@ -70,7 +70,22 @@ an error message and a lost thought.
|
||||
write it, and guessing month-first would silently move a date by months rather
|
||||
than failing visibly.
|
||||
|
||||
## The shortcuts
|
||||
## Just start typing
|
||||
|
||||
Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for
|
||||
Upcoming, ++cmd+k++ to search, and ++question++ for the full list.
|
||||
There is no shortcut to remember. Press a letter anywhere in the app and you are
|
||||
writing a task, with the letter you pressed already in the field. The thought does
|
||||
not have to survive a journey to find somewhere to put it.
|
||||
|
||||
It only fires when nothing else has focus — a key pressed at a field, a button or
|
||||
a link belongs to that thing.
|
||||
|
||||
Under the field is a line saying what the parser reads: `#` project, `/` section,
|
||||
`@` label, `p1` priority, and an example date. It appears while you are typing,
|
||||
which is the one moment a placeholder cannot help you.
|
||||
|
||||
++cmd+k++ opens the palette — search, and navigation to anywhere in the app.
|
||||
|
||||
!!! note "The single letters used to navigate"
|
||||
++t++ for Today and ++u++ for Upcoming are gone. One keyboard cannot both
|
||||
spell *tal med Anders* and jump to Today on the same ++t++, and capturing is
|
||||
the thing done fifty times a day. The palette does the rest.
|
||||
|
||||
|
After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,39 @@
|
||||
# Signing in
|
||||
|
||||
Three ways in, and you can have all of them at once.
|
||||
|
||||
## A password
|
||||
|
||||
What the first account is created with. Nothing is emailed, nothing is registered
|
||||
anywhere: the first person to open a fresh instance creates the administrator
|
||||
account, and after that people arrive by invite link.
|
||||
|
||||
## A code on top of it
|
||||
|
||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||
app reads the QR code. Once it is on, the password alone is not enough.
|
||||
|
||||
Recovery codes are shown once, when you turn it on. They are the way back if the
|
||||
phone is gone, and they are not shown again.
|
||||
|
||||
## A passkey
|
||||
|
||||
**Settings → Account → Passkeys → Add a passkey.**
|
||||
|
||||
A key on a device instead of a secret in your head. The private half never leaves
|
||||
the device — a phone's secure element, a laptop's keychain, a hardware key — and
|
||||
the signature is bound to the address it was made for, so a convincing copy of the
|
||||
sign-in page at a different address gets nothing.
|
||||
|
||||
Give each one a name you will recognise later. *min bærbare* is what makes a list
|
||||
of keys reviewable, and a list nobody can read is a list nobody revokes from.
|
||||
|
||||
Passkeys need the address in `VERDANDE_BASE_URL` to be a hostname. Browsers refuse
|
||||
to make one for a bare IP address, so an instance reached at `http://192.168.1.10:8080`
|
||||
will show the passkey section as unavailable rather than failing at the moment
|
||||
somebody tries.
|
||||
|
||||
!!! note "One is enough, two is better"
|
||||
Lose the only device holding your only passkey and you are back to the
|
||||
password. Keep the password, or register a second key — a phone as well as a
|
||||
laptop.
|
||||
@@ -3,21 +3,33 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.55.0
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -2,12 +2,33 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -18,6 +39,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
@@ -25,20 +48,60 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -76,7 +78,16 @@ func New(cfg Config) *Client {
|
||||
// Generous: a local model on modest hardware can take a while to produce
|
||||
// its first token, and the alternative is a timeout that only ever fires
|
||||
// for the people running their own.
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
// safedial, not a plain client. `base_url` is typed into a field in the
|
||||
// interface, so it is a user-supplied address — and a server that fetches a
|
||||
// user-supplied address is a way to ask it to reach things the caller
|
||||
// cannot: the panel next door, a database on the same bridge, the cloud
|
||||
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||
//
|
||||
// The check is on the resolved address rather than on the URL, because a
|
||||
// name answers whatever its owner says — and can answer differently the
|
||||
// second time, after a parse-time check has passed.
|
||||
http: safedial.Client(120 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +279,35 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
// The provider's own message is included: for a local model it is usually
|
||||
// the actual problem ("model not found"), and hiding it would leave the
|
||||
// operator with nothing to go on.
|
||||
return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
|
||||
// The provider's body is no longer passed back to the caller.
|
||||
//
|
||||
// It was, and combined with a free-text base URL that made a working
|
||||
// internal scanner: point it at a host, read the first three hundred bytes
|
||||
// of whatever answered. The operator still needs the message — for a local
|
||||
// model it is usually the actual problem — so it goes to the log, where it
|
||||
// is available to the person running the instance and to nobody else.
|
||||
return &upstreamError{Host: req.URL.Host, Status: resp.Status, Body: truncate(string(raw), 300)}
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
// upstreamError keeps the provider's own words for the log and away from the
|
||||
// response. Error() is what reaches a caller; Detail() is what is written down.
|
||||
type upstreamError struct {
|
||||
Host string
|
||||
Status string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *upstreamError) Error() string {
|
||||
return fmt.Sprintf("ai: %s said %s", e.Host, e.Status)
|
||||
}
|
||||
|
||||
// Detail is the whole of it, for the instance's own error log.
|
||||
func (e *upstreamError) Detail() string {
|
||||
return fmt.Sprintf("ai: %s said %s: %s", e.Host, e.Status, e.Body)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
|
||||
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// The relying party is derived from the instance's own base URL, and the two ways
|
||||
// that goes wrong are both silent until a browser refuses.
|
||||
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
base string
|
||||
ok bool
|
||||
why string
|
||||
}{
|
||||
{"https://verdande.example.dk", true, "a real domain"},
|
||||
{"http://localhost:5173", true, "localhost, which every browser excepts"},
|
||||
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
|
||||
|
||||
// An IP address is not a domain. The browser throws SecurityError long
|
||||
// after the server has happily issued a challenge, so it is refused here
|
||||
// and the endpoints answer 503 instead.
|
||||
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
|
||||
{"http://[::1]:8080", false, "an IPv6 literal"},
|
||||
{"", false, "no address at all"},
|
||||
{"not a url", false, "not an address"},
|
||||
} {
|
||||
_, err := NewWebAuthn(c.base, "verdande")
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// Passkeys, wrapped so the rest of the app never touches the protocol types.
|
||||
//
|
||||
// A password and a TOTP code are both shared secrets: the server keeps something
|
||||
// that can be stolen from it, and the person keeps something that can be talked
|
||||
// out of them. A passkey is neither. The private half never leaves the device, and
|
||||
// the signature is bound to the origin that asked for it — so a convincing copy of
|
||||
// the sign-in page at another address gets a signature it cannot use.
|
||||
//
|
||||
// That binding is also the one thing that can be got wrong here, and it is
|
||||
// invisible when it is: the RP ID has to be the registrable domain of the address
|
||||
// people actually visit. Get it wrong and registration works, login works on your
|
||||
// machine, and everybody behind a different hostname is quietly locked out.
|
||||
|
||||
// WebAuthn is the relying party — this instance, as an authenticator sees it.
|
||||
type WebAuthn struct{ *webauthn.WebAuthn }
|
||||
|
||||
// NewWebAuthn derives the relying party from the instance's own base URL.
|
||||
//
|
||||
// Derived rather than configured, because a second setting that has to agree with
|
||||
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
|
||||
// the failure is a login that cannot be completed, reported as "the button does
|
||||
// nothing".
|
||||
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
|
||||
}
|
||||
|
||||
// The RP ID is the host without the port. A port in it is rejected by every
|
||||
// browser, and localhost:5173 during development is exactly where somebody
|
||||
// would first put one.
|
||||
rpID := parsed.Hostname()
|
||||
|
||||
// An IP address is not a domain, and no browser will accept one as an RP ID —
|
||||
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
|
||||
// the server has happily issued a challenge. Refused here instead, so the
|
||||
// endpoints answer 503 and the interface never offers a button that cannot
|
||||
// work. `localhost` is the exception every browser makes, and it is what
|
||||
// development runs on.
|
||||
if ip := net.ParseIP(rpID); ip != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"auth: passkeys need a domain name, and %q is an IP address — "+
|
||||
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
|
||||
}
|
||||
|
||||
w, err := webauthn.New(&webauthn.Config{
|
||||
RPID: rpID,
|
||||
RPDisplayName: displayName,
|
||||
// The origin is the full scheme://host:port, which is what the browser
|
||||
// sends and what the signature is checked against.
|
||||
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: passkeys: %w", err)
|
||||
}
|
||||
return &WebAuthn{w}, nil
|
||||
}
|
||||
|
||||
// A PasskeyUser is what the library needs to know about an account.
|
||||
//
|
||||
// The handle is the account id rather than anything a person chose. It is written
|
||||
// into the credential on the device and cannot be changed afterwards, so it must
|
||||
// not be an email address: changing your email would orphan every key you own.
|
||||
type PasskeyUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
|
||||
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
|
||||
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
|
||||
|
||||
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
|
||||
// go in a BLOB well enough, but the id is also compared against what the browser
|
||||
// sends back as base64url, and one encoding on both sides is one thing to be
|
||||
// wrong about.
|
||||
func EncodeCredentialID(raw []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func DecodeCredentialID(encoded string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(encoded)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package beacon answers one question for the project, and refuses to answer any
|
||||
// other: how many installations are out there.
|
||||
//
|
||||
// It sends two values once a day — an anonymous id the installation made up for
|
||||
// itself, and the version it is running — and nothing else. No IP is stored by
|
||||
// the collector, no hostname, no domain, no account, no usage. The payload is
|
||||
// small enough to be printed in full on the settings page, and it is, because a
|
||||
// promise about telemetry is worth exactly as much as the reader's ability to
|
||||
// check it.
|
||||
//
|
||||
// # On by default, and off in one click
|
||||
//
|
||||
// The alternative that was asked for first was mandatory, and the argument
|
||||
// against it is practical rather than principled: it cannot be enforced. The
|
||||
// domain can be blocked, the binary can be patched, and a fork removes it in its
|
||||
// first commit — so mandatory buys nothing over on-by-default, while costing the
|
||||
// trust the rest of this program works for. Secrets are sealed here, the key is
|
||||
// kept out of the database, and the backup page warns you what a copy contains.
|
||||
// Telemetry you are not allowed to refuse is the one sentence in that story that
|
||||
// does not fit.
|
||||
//
|
||||
// So: on by default, said plainly, one switch to turn it off. That collects
|
||||
// essentially the same number and leaves nobody feeling tricked.
|
||||
//
|
||||
// # Where this disagrees with the rest of the program
|
||||
//
|
||||
// `config.UpdateCheck` is off unless asked for, and its comment gives the reason:
|
||||
// "a self-hosted app that reaches out without being told to has broken the deal
|
||||
// its operator made by self-hosting." That is the same situation as this one, and
|
||||
// the two defaults do not agree.
|
||||
//
|
||||
// The disagreement is written down here rather than smoothed over, because it is
|
||||
// a real one and the reader deserves to see both halves. The case for treating
|
||||
// them differently is that the update check reveals an instance to a third party
|
||||
// (GitHub) and returns something the operator can act on, while this returns
|
||||
// nothing and goes to the project itself — and that a count nobody sends is a
|
||||
// count that does not exist, whereas an update nobody checks for is one you can
|
||||
// still go and look up. Whether that is a distinction or an excuse is a judgement
|
||||
// call, and it was made deliberately by the person whose instance this is.
|
||||
//
|
||||
// If it is ever decided the other way, the change is one word: the default in
|
||||
// `beaconSettings`. Nothing else in this package assumes which way it points.
|
||||
//
|
||||
// # What the collector can still see
|
||||
//
|
||||
// A request has a source address whether or not anybody writes it down. The
|
||||
// collector does not store or log it, and the code is arranged so that it never
|
||||
// reaches a function that could — but it passes through a reverse proxy and a
|
||||
// tunnel on the way, and those keep their own logs. Saying "we send no IP" would
|
||||
// be a half-truth; what is true is that nothing here records one.
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultCollector is where a fresh installation reports, unless it is told
|
||||
// otherwise. Somebody running their own fleet points it at their own instance;
|
||||
// the field is on the settings page for exactly that.
|
||||
const DefaultCollector = "https://verdande.yggdrasilpanel.com/api/v1/beacon"
|
||||
|
||||
// Payload is the entire message. Two fields, and adding a third is a change to
|
||||
// what this program promises, not a change to a struct.
|
||||
type Payload struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InstanceID and Version are checked on the way in as well as written on the way
|
||||
// out, because the collector's endpoint is unauthenticated: anything that reaches
|
||||
// it is a stranger's bytes, and it should be able to store only the two shapes it
|
||||
// expects.
|
||||
var (
|
||||
idShape = regexp.MustCompile(`^[0-9a-fA-F-]{16,64}$`)
|
||||
versionShape = regexp.MustCompile(`^v?[0-9]{1,4}(\.[0-9]{1,5}){0,3}(-[0-9A-Za-z.]{1,20})?$`)
|
||||
)
|
||||
|
||||
// ValidID reports whether an id is the shape this program writes.
|
||||
func ValidID(s string) bool { return idShape.MatchString(s) }
|
||||
|
||||
// ValidVersion allows a version number and the word this binary uses when it was
|
||||
// built outside a release.
|
||||
func ValidVersion(s string) bool { return s == "dev" || versionShape.MatchString(s) }
|
||||
|
||||
// Send delivers one ping. It is deliberately dull: a short timeout, no retry, no
|
||||
// queue, and a failure that is returned rather than made anybody's problem.
|
||||
//
|
||||
// No retry, because the next pass is tomorrow and a missed day changes a count of
|
||||
// installations by nothing. A telemetry ping that retries is a telemetry ping
|
||||
// that can hammer somebody's server on the day it is down.
|
||||
func Send(ctx context.Context, client *http.Client, collector string, p Payload) error {
|
||||
if !ValidID(p.InstanceID) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed id")
|
||||
}
|
||||
// Versionen kontrolleres også her, ikke kun hos modtageren. Kommentaren ved
|
||||
// mønstrene lovede begge veje, og gjorde det ikke — og en installation, der
|
||||
// sender noget mærkeligt, skal opdage det hos sig selv frem for at være noget,
|
||||
// en fremmed collector skal filtrere fra.
|
||||
if !ValidVersion(p.Version) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed version")
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collector, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// A plain user agent with the version already in the payload. Nothing is
|
||||
// learned from it that the body does not already say, and it makes the request
|
||||
// legible in a log rather than anonymous-looking.
|
||||
req.Header.Set("User-Agent", "verdande-beacon/1")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Read and discard, so the connection can be reused rather than dropped.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("beacon: collector answered %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Hvad der bliver sendt, tegn for tegn.
|
||||
//
|
||||
// Prøven er skrevet imod *hele* kroppen og ikke imod enkelte felter, fordi det er
|
||||
// den, der er løftet: siden siger "præcis det her og ikke mere", og et felt lagt
|
||||
// til senere skal vælte noget. En prøve, der kun så efter, at instance_id var med,
|
||||
// ville lade en ny nøgle glide igennem uden at nogen opdagede det.
|
||||
func TestSendsExactlyTwoFields(t *testing.T) {
|
||||
var got map[string]any
|
||||
var contentType string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contentType = r.Header.Get("Content-Type")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Errorf("kroppen er ikke JSON: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a",
|
||||
Version: "v0.26.3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q", contentType)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("der blev sendt %d felter, ikke to: %v", len(got), got)
|
||||
}
|
||||
if got["instance_id"] != "01a01b16-4e54-7000-9eaa-2f8eb0629c3a" {
|
||||
t.Errorf("instance_id = %v", got["instance_id"])
|
||||
}
|
||||
if got["version"] != "v0.26.3" {
|
||||
t.Errorf("version = %v", got["version"])
|
||||
}
|
||||
}
|
||||
|
||||
// En collector, der er nede, er ikke denne installations problem.
|
||||
func TestSendReportsAFailureRatherThanRetrying(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a", Version: "v1.0.0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("en 500 skal give en fejl")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("der blev prøvet %d gange; en telemetri-ping må ikke hamre på en server, der er nede", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Et id, der ikke er id-formet, sendes slet ikke — så en fejl her ikke bliver til
|
||||
// noget, en fremmed collector skal filtrere fra.
|
||||
func TestSendRefusesAMalformedID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("der blev sendt noget, som ikke skulle sendes")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "; DROP TABLE beacon_installs", Version: "v1.0.0",
|
||||
}); err == nil {
|
||||
t.Fatal("et misformet id skal afvises før det forlader maskinen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapes(t *testing.T) {
|
||||
ok := []string{"01a01b16-4e54-7000-9eaa-2f8eb0629c3a", "89df44d7-3742-4611-9a43-db0b07733c6d"}
|
||||
for _, s := range ok {
|
||||
if !ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = false", s)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "kort", "../../etc/passwd", "01a01b16-4e54-7000-9eaa-2f8eb0629c3a'; --",
|
||||
"<script>alert(1)</script>"}
|
||||
for _, s := range bad {
|
||||
if ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = true", s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range []string{"v0.26.3", "0.26.3", "v1.2.3-rc.1", "dev"} {
|
||||
if !ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "v<script>", "v" + string(make([]byte, 200))} {
|
||||
if ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,50 @@ type Config struct {
|
||||
GmailClientID string
|
||||
GmailClientSecret string
|
||||
|
||||
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
|
||||
// Empty means a key file beside the data — see internal/secret.
|
||||
SecretKey string
|
||||
|
||||
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
||||
// for: a self-hosted app that reaches out without being told to has broken
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
// A container cannot replace its own image from the inside. Restarting is the
|
||||
// panel's job — it recreates the container and pulls `:latest` on the way — so
|
||||
// what this needs is a way to ask. All three are required together; any one
|
||||
// alone cannot do anything.
|
||||
//
|
||||
// The token is a panel credential with control over that server. Anybody who
|
||||
// can read this environment can restart it, which is true of anybody with the
|
||||
// host anyway. It is never sent to a browser: the settings page is told only
|
||||
// whether it is set.
|
||||
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
|
||||
// front of this server. Configurable so a test can prove the budget is applied
|
||||
// without spending the real one on every run.
|
||||
GmailSyncBudget time.Duration
|
||||
|
||||
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
|
||||
// the same reason: a slow Google held the request open past what Cloudflare is
|
||||
// willing to wait, and the browser got Cloudflare's HTML instead of an error
|
||||
// this server could explain.
|
||||
CalendarSyncBudget time.Duration
|
||||
|
||||
// Where Google is. Empty in production; a test points them at a server it
|
||||
// controls, which is the only way to exercise a Google that is slow or refuses.
|
||||
// The token endpoint is shared — Gmail and Calendar sign in through the same
|
||||
// registration — and the two APIs are not.
|
||||
GoogleTokenURL string
|
||||
GmailAPIURL string
|
||||
CalendarAPIURL string
|
||||
|
||||
PanelURL string
|
||||
PanelToken string
|
||||
PanelServerID string
|
||||
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
@@ -68,9 +107,15 @@ func Load() (*Config, error) {
|
||||
TrashRetention: 30 * 24 * time.Hour,
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
CalendarSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -117,6 +162,15 @@ func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups"
|
||||
// configured separately and left to drift out of step with it.
|
||||
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
|
||||
|
||||
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
|
||||
//
|
||||
// One callback that decided from server-side state which of the two flows had come
|
||||
// back would save the operator one line in Google Cloud, and would put the choice
|
||||
// of *which token store to write* behind a lookup. When that goes wrong it goes
|
||||
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
|
||||
// nobody would think to look for. Two paths cannot do that.
|
||||
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" }
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
|
||||
@@ -60,6 +60,20 @@ func Parse(query string, ctx Context) (Compiled, error) {
|
||||
if len(p.tokens) == 0 {
|
||||
return Compiled{}, fmt.Errorf("filteret er tomt")
|
||||
}
|
||||
// Refused before it is parsed, not while.
|
||||
//
|
||||
// `combine` rebuilds the whole accumulated SQL string and re-copies the
|
||||
// argument slice per operator, so the cost is quadratic in the number of
|
||||
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
|
||||
// query string — took seven seconds of CPU and forty-six gigabytes of
|
||||
// allocation in one request. The preview endpoint runs this on whatever is
|
||||
// typed, and there is no write timeout on the server to cut it off.
|
||||
//
|
||||
// Two hundred terms is a filter nobody has ever written and far below where it
|
||||
// costs anything.
|
||||
if len(p.tokens) > maxTokens {
|
||||
return Compiled{}, fmt.Errorf("filteret er for langt")
|
||||
}
|
||||
|
||||
node, err := p.parseExpression()
|
||||
if err != nil {
|
||||
@@ -140,10 +154,19 @@ func isOperator(r rune) bool {
|
||||
|
||||
// --- parser -------------------------------------------------------------------
|
||||
|
||||
// The ceilings on one filter. See Parse for why.
|
||||
const (
|
||||
maxTokens = 200
|
||||
maxDepth = 32
|
||||
)
|
||||
|
||||
type parser struct {
|
||||
tokens []token
|
||||
pos int
|
||||
ctx Context
|
||||
// depth guards against a filter that is only parentheses. Each level is a
|
||||
// stack frame, and nothing else stops somebody sending a megabyte of them.
|
||||
depth int
|
||||
}
|
||||
|
||||
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
|
||||
@@ -187,6 +210,13 @@ func (p *parser) parseTerm() (Compiled, error) {
|
||||
if p.done() {
|
||||
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
|
||||
}
|
||||
// Nesting is stack depth. A filter made of half a million opening parentheses
|
||||
// is a megabyte of text and a stack frame for each.
|
||||
p.depth++
|
||||
defer func() { p.depth-- }()
|
||||
if p.depth > maxDepth {
|
||||
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
|
||||
}
|
||||
|
||||
switch t := p.peek(); t.kind {
|
||||
case tokNot:
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Package gcal reads a Google Calendar. It never writes one.
|
||||
//
|
||||
// The scope is calendar.readonly, and that is the whole of the decision: verdande
|
||||
// already is a CalDAV server, so a Google event shown here is a second calendar
|
||||
// laid over the first, not a copy Google and verdande now both have to agree
|
||||
// about. Writing needs `calendar.events`, and it needs an answer to what happens
|
||||
// when both sides moved the same meeting — which is a synchronisation model, not a
|
||||
// scope. Read first; write later and to one calendar at a time.
|
||||
//
|
||||
// What the API gives back and what this returns are deliberately different shapes.
|
||||
// Google answers with RFC 3339 instants for a timed event and bare dates for an
|
||||
// all-day one, and with an *exclusive* end date for the second. A calendar grid
|
||||
// asks one question — "which cells does this event cover" — so that is what an
|
||||
// Event answers, in days, worked out once here rather than in every caller.
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
// Scope is read-only, and only the calendar.
|
||||
//
|
||||
// Google also offers calendar.calendarlist.readonly and calendar.events.readonly,
|
||||
// which together are strictly less than this — they leave out access rules and
|
||||
// account settings, which verdande has no use for. They are the tighter pair and
|
||||
// worth moving to; they are also newer, and this one has been documented and
|
||||
// accepted unchanged for a decade. A consent screen that fails with `invalid_scope`
|
||||
// is not a place to be adventurous on somebody else's Google account.
|
||||
const Scope = "https://www.googleapis.com/auth/calendar.readonly"
|
||||
|
||||
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
// The OAuth flow, which is Google's rather than the calendar's — the same one
|
||||
// Gmail signs in through, asking for the scope above. Aliased so a caller reads
|
||||
// one package for one feature.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
var NewPKCE = google.NewPKCE
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
|
||||
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
|
||||
var ErrUnauthorized = google.ErrUnauthorized
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
// base is where the Calendar API is. A field rather than a constant so a test
|
||||
// can point it at a server it controls; empty is Google. There is no other way
|
||||
// to exercise a calendar that is slow, empty, or refuses.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Calendar is one entry in the person's calendar list.
|
||||
type Calendar struct {
|
||||
ID string
|
||||
// Name is the summary Google holds. A calendar somebody renamed for themselves
|
||||
// carries summaryOverride instead, and that is the name they will look for.
|
||||
Name string
|
||||
// Colour is Google's own hex value for the calendar, which is what makes two
|
||||
// calendars in one grid tellable apart. Their own colour rather than one picked
|
||||
// here: it is the colour they already know the calendar by.
|
||||
Colour string
|
||||
TimeZone string
|
||||
Primary bool
|
||||
// Owner means the connected account can write to it. Nothing writes today —
|
||||
// it is stored so the day something does, the interface knows which calendars
|
||||
// could even be offered.
|
||||
Owner bool
|
||||
}
|
||||
|
||||
// Calendars lists what the connected account can see.
|
||||
func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) {
|
||||
var out []Calendar
|
||||
page := ""
|
||||
// Bounded rather than "until Google stops": a page token that never empties is
|
||||
// a loop that never ends, and the difference between a bug at Google and a
|
||||
// hung sync should not be this server hanging.
|
||||
for range 10 {
|
||||
v := url.Values{}
|
||||
v.Set("minAccessRole", "reader")
|
||||
v.Set("maxResults", "250")
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
SummaryOverride string `json:"summaryOverride"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
Primary bool `json:"primary"`
|
||||
AccessRole string `json:"accessRole"`
|
||||
Deleted bool `json:"deleted"`
|
||||
} `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
if item.Deleted {
|
||||
continue
|
||||
}
|
||||
name := item.SummaryOverride
|
||||
if name == "" {
|
||||
name = item.Summary
|
||||
}
|
||||
out = append(out, Calendar{
|
||||
ID: item.ID, Name: name, Colour: item.BackgroundColor,
|
||||
TimeZone: item.TimeZone, Primary: item.Primary,
|
||||
Owner: item.AccessRole == "owner" || item.AccessRole == "writer",
|
||||
})
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Event is one occurrence, in the shape a calendar grid asks about.
|
||||
type Event struct {
|
||||
ID string
|
||||
Summary string
|
||||
// StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day
|
||||
// one — an all-day event has no instant, only a day, and inventing midnight for
|
||||
// it is how an event moves a day every time a server changes time zone.
|
||||
StartsAt string
|
||||
EndsAt string
|
||||
// StartDay and EndDay are the first and last day the event covers, inclusive at
|
||||
// both ends. Google's all-day end date is exclusive; that is corrected here,
|
||||
// once, rather than in every reader.
|
||||
StartDay string
|
||||
EndDay string
|
||||
AllDay bool
|
||||
Location string
|
||||
// URL opens the event in Google's own interface. Same reasoning as the Gmail
|
||||
// link on a task made from a mail: what verdande shows is a pointer to the
|
||||
// event, not a second copy of it that somebody could try to edit.
|
||||
URL string
|
||||
}
|
||||
|
||||
// Events lists what falls inside a window, one calendar at a time.
|
||||
//
|
||||
// `singleEvents` expands a recurrence into its occurrences, which is the only form
|
||||
// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday
|
||||
// it produced. It also means verdande does not have to grow a second recurrence
|
||||
// engine that agrees with Google's about Danish summer time.
|
||||
//
|
||||
// from and to are dates, inclusive. The window is the caller's business; this
|
||||
// turns it into the half-open interval Google wants.
|
||||
func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) {
|
||||
start, err := time.Parse(dateLayout, from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: from: %w", err)
|
||||
}
|
||||
end, err := time.Parse(dateLayout, to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: to: %w", err)
|
||||
}
|
||||
|
||||
var out []Event
|
||||
page := ""
|
||||
for range 20 {
|
||||
v := url.Values{}
|
||||
v.Set("singleEvents", "true")
|
||||
v.Set("orderBy", "startTime")
|
||||
v.Set("maxResults", "2500")
|
||||
// UTC, and a day of slack at each end. An event at 23:30 on the last day in
|
||||
// Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is
|
||||
// 22:30 UTC the day *before* — so a window cut exactly at the requested
|
||||
// dates loses the events at both edges. The extra day is thrown away again
|
||||
// by the day fields below, which are computed from the event's own offset.
|
||||
v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339))
|
||||
v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339))
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []rawEvent `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode()
|
||||
if err := c.get(ctx, url, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
// A cancelled occurrence of a recurring event still comes back, so that
|
||||
// a client keeping its own copy knows to drop it. verdande replaces the
|
||||
// window wholesale, so it only has to not add it.
|
||||
if item.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
event, ok := item.event()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if event.EndDay < from || event.StartDay > to {
|
||||
continue // the slack above, thrown away
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
type rawEvent struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Location string `json:"location"`
|
||||
HTMLLink string `json:"htmlLink"`
|
||||
Start when `json:"start"`
|
||||
End when `json:"end"`
|
||||
}
|
||||
|
||||
type when struct {
|
||||
Date string `json:"date"`
|
||||
DateTime string `json:"dateTime"`
|
||||
}
|
||||
|
||||
// event turns Google's two shapes into the one a grid can read.
|
||||
//
|
||||
// The second return is false for an event with neither a date nor a dateTime —
|
||||
// which Google does return, for an event somebody is still being asked about. It
|
||||
// belongs on no day, so there is no cell to draw it in.
|
||||
func (r rawEvent) event() (Event, bool) {
|
||||
e := Event{
|
||||
ID: r.ID, Summary: strings.TrimSpace(r.Summary),
|
||||
Location: r.Location, URL: r.HTMLLink,
|
||||
}
|
||||
if e.Summary == "" {
|
||||
// Google's own interface says this for an event with no title, and a blank
|
||||
// chip in a grid reads as a rendering fault rather than as an empty title.
|
||||
e.Summary = "(uden titel)"
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Start.Date != "":
|
||||
e.AllDay = true
|
||||
e.StartDay = r.Start.Date
|
||||
// Google's all-day end is the day *after* the last one, so a one-day event
|
||||
// ends tomorrow. Subtracting a day is what makes "covers this cell" a
|
||||
// comparison rather than a special case at every call site.
|
||||
e.EndDay = r.Start.Date
|
||||
if day, err := time.Parse(dateLayout, r.End.Date); err == nil {
|
||||
if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) {
|
||||
e.EndDay = last.Format(dateLayout)
|
||||
}
|
||||
}
|
||||
case r.Start.DateTime != "":
|
||||
e.StartsAt = r.Start.DateTime
|
||||
e.EndsAt = r.End.DateTime
|
||||
// The day is the date part of the timestamp Google returned, which carries
|
||||
// the event's own offset. Parsing it into a time.Time and formatting it
|
||||
// back would ask *this process* what day it is — and a container running in
|
||||
// UTC would then move a 23:30 meeting in Copenhagen to the day before.
|
||||
e.StartDay = dayOf(r.Start.DateTime)
|
||||
e.EndDay = dayOf(r.End.DateTime)
|
||||
if e.EndDay == "" {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
// An event ending at midnight ends the evening before, as far as a grid is
|
||||
// concerned. Without this, every meeting that runs to 24:00 puts a chip in
|
||||
// the following day as well.
|
||||
if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") {
|
||||
if day, err := time.Parse(dateLayout, e.EndDay); err == nil {
|
||||
e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return Event{}, false
|
||||
}
|
||||
if e.EndDay < e.StartDay {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
// dayOf takes the date out of an RFC 3339 timestamp without interpreting it.
|
||||
func dayOf(stamp string) string {
|
||||
if len(stamp) < len(dateLayout) {
|
||||
return ""
|
||||
}
|
||||
day := stamp[:len(dateLayout)]
|
||||
if _, err := time.Parse(dateLayout, day); err != nil {
|
||||
return ""
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
func mustDay(s string) time.Time {
|
||||
day, _ := time.Parse(dateLayout, s)
|
||||
return day
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGoogle answers the two calls this package makes, out of whatever the test
|
||||
// hands it. There is no other way to exercise this: the real Calendar needs an
|
||||
// account, a registered client and somebody's consent, and none of the three can be
|
||||
// had inside a test — so the shapes Google returns are written down here and the
|
||||
// parsing is checked against them.
|
||||
func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(calendarList)
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
body, ok := events[id]
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
default:
|
||||
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return NewClient("token").At(srv.URL)
|
||||
}
|
||||
|
||||
func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) {
|
||||
client := fakeGoogle(t, map[string]any{
|
||||
"items": []map[string]any{
|
||||
{"id": "primary", "summary": "[email protected]", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"},
|
||||
{"id": "fam", "summary": "Family", "summaryOverride": "Familien",
|
||||
"backgroundColor": "#0b8043", "accessRole": "reader"},
|
||||
// A calendar removed from the list still arrives, flagged.
|
||||
{"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
got, err := client.Calendars(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got))
|
||||
}
|
||||
if got[1].Name != "Familien" {
|
||||
t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name)
|
||||
}
|
||||
if !got[0].Primary || !got[0].Owner {
|
||||
t.Errorf("the primary calendar came back as %+v", got[0])
|
||||
}
|
||||
if got[1].Owner {
|
||||
t.Error("a reader was reported as an owner; nothing may offer to write to it")
|
||||
}
|
||||
if got[0].Colour != "#3f51b5" {
|
||||
t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour)
|
||||
}
|
||||
}
|
||||
|
||||
// The three shapes an event arrives in, and the one shape a grid can read.
|
||||
func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"primary": map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"},
|
||||
},
|
||||
{
|
||||
// All-day, one day. Google's end is the day after.
|
||||
"id": "allday", "summary": "Fridag",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"},
|
||||
},
|
||||
{
|
||||
// All-day, three days.
|
||||
"id": "trip", "summary": "Aarhus",
|
||||
"start": map[string]string{"date": "2026-08-24"},
|
||||
"end": map[string]string{"date": "2026-08-27"},
|
||||
},
|
||||
{
|
||||
// Runs to midnight, which is the evening before as far as a
|
||||
// grid is concerned.
|
||||
"id": "late", "summary": "Nattevagt",
|
||||
"start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"},
|
||||
},
|
||||
{
|
||||
// A cancelled occurrence of a recurring event.
|
||||
"id": "off", "status": "cancelled", "summary": "Aflyst",
|
||||
"start": map[string]string{"date": "2026-08-25"},
|
||||
"end": map[string]string{"date": "2026-08-26"},
|
||||
},
|
||||
{
|
||||
// Neither a date nor a dateTime: belongs on no day at all.
|
||||
"id": "nowhere", "summary": "Uden tid",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byID := map[string]Event{}
|
||||
for _, e := range got {
|
||||
byID[e.ID] = e
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d events, want 4: %+v", len(got), got)
|
||||
}
|
||||
if _, ok := byID["off"]; ok {
|
||||
t.Error("a cancelled occurrence was kept; it is an event that is not happening")
|
||||
}
|
||||
if _, ok := byID["nowhere"]; ok {
|
||||
t.Error("an event with no time was kept; there is no cell to draw it in")
|
||||
}
|
||||
|
||||
timed := byID["timed"]
|
||||
if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" {
|
||||
t.Errorf("timed = %+v", timed)
|
||||
}
|
||||
if timed.StartsAt != "2026-08-20T14:00:00+02:00" {
|
||||
t.Errorf("the instant was rewritten: %q", timed.StartsAt)
|
||||
}
|
||||
|
||||
allday := byID["allday"]
|
||||
if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" {
|
||||
t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here",
|
||||
allday)
|
||||
}
|
||||
if allday.StartsAt != "" {
|
||||
t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment",
|
||||
allday.StartsAt)
|
||||
}
|
||||
|
||||
if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" {
|
||||
t.Errorf("trip = %+v, want 24th to 26th inclusive", trip)
|
||||
}
|
||||
if late := byID["late"]; late.EndDay != "2026-08-22" {
|
||||
t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+
|
||||
"or every one of them puts a chip in the following day", late.EndDay)
|
||||
}
|
||||
}
|
||||
|
||||
// An event with no title has to say so. A blank chip in a grid reads as a
|
||||
// rendering fault rather than as an empty title.
|
||||
func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{{
|
||||
"id": "x",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"},
|
||||
}}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Summary == "" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The window is asked for with a day of slack at each end, because an event late
|
||||
// in the evening in Copenhagen is the next day in UTC. The slack must not leak
|
||||
// out: an event on the day before the window is not in the window.
|
||||
func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{
|
||||
{"id": "before", "summary": "For tidligt",
|
||||
"start": map[string]string{"date": "2026-08-09"},
|
||||
"end": map[string]string{"date": "2026-08-10"}},
|
||||
{"id": "inside", "summary": "Med",
|
||||
"start": map[string]string{"date": "2026-08-10"},
|
||||
"end": map[string]string{"date": "2026-08-11"}},
|
||||
{"id": "after", "summary": "For sent",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "inside" {
|
||||
t.Fatalf("got %+v, want only the one inside the window", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A page token has to be followed, or a busy calendar silently shows its first
|
||||
// page and nothing else — which is a calendar that lies about a day being clear.
|
||||
func TestEveryPageIsRead(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
body := map[string]any{
|
||||
"items": []map[string]any{{"id": "a", "summary": "Første",
|
||||
"start": map[string]string{"date": "2026-08-11"},
|
||||
"end": map[string]string{"date": "2026-08-12"}}},
|
||||
"nextPageToken": "more",
|
||||
}
|
||||
if r.URL.Query().Get("pageToken") == "more" {
|
||||
body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden",
|
||||
"start": map[string]string{"date": "2026-08-12"},
|
||||
"end": map[string]string{"date": "2026-08-13"}}}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want both pages: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// A 401 is not a failure to retry; it is a connection that has ended. Telling the
|
||||
// two apart is what stops a revoked account being polled every ten minutes for ever.
|
||||
func TestARefusedTokenIsItsOwnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := NewClient("spent").At(srv.URL).Calendars(t.Context())
|
||||
if err != ErrUnauthorized {
|
||||
t.Fatalf("err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only, and only the calendar. The consent screen is what somebody agrees to,
|
||||
// and a scope that quietly grew is a promise quietly broken.
|
||||
func TestTheScopeIsReadOnly(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "calendar.readonly") {
|
||||
t.Errorf("scope = %q; nothing here writes a calendar", Scope)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google, and the small slice of the Gmail API verdande needs.
|
||||
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||
// it asks for.
|
||||
//
|
||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
||||
@@ -9,175 +9,70 @@
|
||||
//
|
||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
||||
// asking for less is what makes the consent screen something a person can agree to.
|
||||
//
|
||||
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
||||
// The names below are aliases so that every call site — and this package's own
|
||||
// tests — still read as Gmail's, which is what they are: one registration, one
|
||||
// flow, one scope each.
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
tokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
apiBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
// The OAuth flow, which is Google's rather than Gmail's.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
||||
// the instance rather than to a person: one registration, any number of users.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("gmail: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
var (
|
||||
NewPKCE = google.NewPKCE
|
||||
ErrUnauthorized = google.ErrUnauthorized
|
||||
)
|
||||
|
||||
// --- the API ---------------------------------------------------------------------
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
http *http.Client
|
||||
// base is where the Gmail API is. A field rather than a constant so a test can
|
||||
// point it at a server it controls — and "the sync comes back before whatever
|
||||
// sits in front of this server gives up on it" is exactly the kind of promise
|
||||
// worth a test and impossible to make against the real thing. Empty is Google.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Message is the part of a Gmail message a task is made from.
|
||||
type Message struct {
|
||||
ID string
|
||||
@@ -196,7 +91,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
||||
var parsed struct {
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.EmailAddress, nil
|
||||
@@ -220,7 +115,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -251,7 +146,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
@@ -271,31 +166,9 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gmail: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused")
|
||||
|
||||
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
|
||||
// falling back to the address when there is no display name.
|
||||
func SenderName(from string) string {
|
||||
@@ -338,11 +211,3 @@ func Query(trigger, label string) string {
|
||||
terms = append(terms, "newer_than:30d")
|
||||
return strings.Join(terms, " ")
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
|
||||
@@ -1,113 +1,19 @@
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
// Read-only, and only mail.
|
||||
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||
// one the person reads on the consent screen. The flow itself is exercised in
|
||||
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
||||
func TestTheScopeIsReadOnlyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
trigger, label string
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package google implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google.
|
||||
//
|
||||
// It was written inside internal/gmail, because Gmail was the only thing that
|
||||
// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the
|
||||
// same refresh — and the only thing that differs between them is one string: the
|
||||
// scope. A second copy would be a second place for "access_type=offline" to be
|
||||
// forgotten, which is the mistake that makes a connection die silently an hour
|
||||
// after it was made.
|
||||
//
|
||||
// What is *not* here is the API calls. Those differ completely, so gmail and gcal
|
||||
// keep their own clients and share only the way in.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
)
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud, plus the
|
||||
// scope one feature is asking for.
|
||||
//
|
||||
// The client belongs to the instance rather than to a person: one registration,
|
||||
// any number of users. The scope belongs to the feature: Gmail and Calendar sign
|
||||
// in through the same registration and ask for different things, and Google issues
|
||||
// a refresh token per authorisation — so connecting one does not disturb the other.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Scope string
|
||||
|
||||
// TokenURL is where Google is. A field rather than a constant so a test can
|
||||
// point it at a server it controls: there is no other way to exercise what
|
||||
// happens when the token endpoint is slow or refuses. Empty is Google, so
|
||||
// nothing outside a test has to know it exists.
|
||||
TokenURL string
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
func (c Config) tokenURL() string {
|
||||
if c.TokenURL != "" {
|
||||
return c.TokenURL
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", c.Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("google: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("google: authorisation was refused")
|
||||
|
||||
// Get performs an authorised GET and decodes the JSON body into out.
|
||||
//
|
||||
// Shared by the two API clients because the interesting part is the same in both:
|
||||
// a 401 is not a failure to retry, it is a connection that has ended, and telling
|
||||
// those apart is what stops a revoked account being polled every ten minutes for
|
||||
// ever.
|
||||
func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("google: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
Scope: "https://www.googleapis.com/auth/gmail.readonly",
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": cfg.Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
}
|
||||
|
||||
// The scope is the one thing that differs between the two features signing in
|
||||
// through this, so it has to come out of the config rather than out of a constant
|
||||
// somebody could forget to change.
|
||||
func TestTheScopeComesFromTheConfig(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calendar := "https://www.googleapis.com/auth/calendar.readonly"
|
||||
raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p)
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parsed.Query().Get("scope"); got != calendar {
|
||||
t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+
|
||||
"that grants the wrong thing", got, calendar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// What has happened on this server, across every project.
|
||||
//
|
||||
// The per-project history under /projects/{id}/activity is the same rows read by
|
||||
// the people who work in that project. This is the administrator's version of the
|
||||
// question, and it is a different one: an administrator is not necessarily a member
|
||||
// of the project they need to look into, and "which account did that" cannot be
|
||||
// asked one project at a time.
|
||||
//
|
||||
// It sits beside the error log for a reason. That one is the half about what broke;
|
||||
// this is the half about what was done, and an instance had only ever had the first.
|
||||
|
||||
type auditEntryJSON struct {
|
||||
ID string `json:"id"`
|
||||
At string `json:"at"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
// UserID and UserName are absent when the account has been deleted. The row
|
||||
// stays — an audit log that lost its rows when somebody left would be missing
|
||||
// exactly the part worth keeping.
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
Event string `json:"event"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
f := store.AuditFilter{
|
||||
UserID: q.Get("user_id"),
|
||||
ProjectID: q.Get("project_id"),
|
||||
Event: q.Get("event"),
|
||||
Limit: parseLimit(q.Get("limit"), 100, 500),
|
||||
}
|
||||
if cursor := q.Get("before"); cursor != "" {
|
||||
at, id, ok := parseAuditCursor(cursor)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"before must be <unix>:<id>, as next_cursor returns it")
|
||||
return
|
||||
}
|
||||
f.BeforeAt, f.BeforeID = at, id
|
||||
}
|
||||
|
||||
entries, err := s.db.ListAuditLog(r.Context(), f)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list audit log", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]auditEntryJSON, 0, len(entries))
|
||||
for _, a := range entries {
|
||||
out = append(out, auditEntryJSON{
|
||||
ID: a.ID, At: a.CreatedAt.Format(time.RFC3339),
|
||||
ProjectID: a.ProjectID, ProjectName: a.ProjectName, TaskID: a.TaskID,
|
||||
UserID: a.UserID, UserName: a.UserName, Event: a.Event, Payload: a.Payload,
|
||||
})
|
||||
}
|
||||
|
||||
body := map[string]any{"activity": out}
|
||||
// A cursor only when the page was full. A short page is the end of the log, and
|
||||
// a cursor there invites one more request that can only come back empty.
|
||||
if len(entries) == f.Limit && f.Limit > 0 {
|
||||
last := entries[len(entries)-1]
|
||||
body["next_cursor"] = strconv.FormatInt(last.CreatedAt.Unix(), 10) + ":" + last.ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleAuditEvents is what the event filter is drawn from: the names that occur,
|
||||
// with counts. Offered rather than typed — "member.role_changed" is an internal
|
||||
// vocabulary, and a text field for it is a field that returns nothing until you
|
||||
// have read the source.
|
||||
func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
events, err := s.db.AuditEvents(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "list audit events", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(events))
|
||||
for _, e := range events {
|
||||
out = append(out, map[string]any{"event": e.Event, "count": e.Count})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": out})
|
||||
}
|
||||
|
||||
// parseAuditCursor reads "<unix>:<id>" — the pair the keyset walk needs. Both
|
||||
// halves are required: a cursor carrying only a timestamp cannot separate two rows
|
||||
// written in the same second, which is the case it exists for.
|
||||
func parseAuditCursor(s string) (int64, string, bool) {
|
||||
at, id, found := strings.Cut(s, ":")
|
||||
if !found || id == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
unix, err := strconv.ParseInt(at, 10, 64)
|
||||
if err != nil || unix <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return unix, id, true
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The point of the instance-wide log is that it crosses projects the caller is not
|
||||
// a member of. The per-project endpoint cannot answer this question, and an
|
||||
// administrator asking "what has happened on this server" was previously told to
|
||||
// go and look in each project they happen to belong to.
|
||||
func TestTheAuditLogCrossesProjectsTheAdminIsNotInAllOfThem(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
// A project the administrator is not a member of.
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres eget"})
|
||||
theirID := theirs["id"].(string)
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "noget i deres eget", "project_id": theirID,
|
||||
})
|
||||
|
||||
// The administrator cannot read that project's own history at all.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/projects/"+theirID+"/activity", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("a project the admin is not in: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The instance log has it.
|
||||
resp, body := ts.do(t, "GET", "/api/v1/activity", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("audit log: status %d", resp.StatusCode)
|
||||
}
|
||||
entries, _ := body["activity"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range entries {
|
||||
e := raw.(map[string]any)
|
||||
if e["project_id"] == theirID && e["event"] == "task.created" {
|
||||
found = e
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("nothing from a project the admin is not in: %v", entries)
|
||||
}
|
||||
if found["project_name"] != "Deres eget" {
|
||||
t.Errorf("project_name = %v — a row that does not name its project says nothing",
|
||||
found["project_name"])
|
||||
}
|
||||
if found["user_name"] != "Anden" {
|
||||
t.Errorf("user_name = %v", found["user_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// Administrators only, and sessions only. An audit log readable with a bearer token
|
||||
// would let a stolen token read the record of the theft — and of everything else on
|
||||
// the instance.
|
||||
func TestTheAuditLogIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
|
||||
if resp, _ := other.do(t, "GET", path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
|
||||
req, _ := http.NewRequest("GET", ts.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s with an admin's API token: status %d, want 403", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor is keyed on (created_at, id) because the log is written in whole
|
||||
// seconds. A page boundary that landed inside a busy second would, with a
|
||||
// timestamp-only cursor, either repeat those rows or skip them — and an audit log
|
||||
// that skips rows is worse than no audit log.
|
||||
func TestTheAuditLogPagesWithoutRepeatingOrSkipping(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Meget"})
|
||||
projectID := project["id"].(string)
|
||||
// Written as fast as the loop goes, so they share timestamps.
|
||||
const written = 25
|
||||
for i := range written {
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": fmt.Sprintf("opgave %d", i), "project_id": projectID,
|
||||
})
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
cursor := ""
|
||||
for page := 0; page < 20; page++ {
|
||||
path := "/api/v1/activity?limit=4"
|
||||
if cursor != "" {
|
||||
path += "&before=" + cursor
|
||||
}
|
||||
resp, body := ts.do(t, "GET", path, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("page %d: status %d", page, resp.StatusCode)
|
||||
}
|
||||
entries, _ := body["activity"].([]any)
|
||||
for _, raw := range entries {
|
||||
id := raw.(map[string]any)["id"].(string)
|
||||
if seen[id] {
|
||||
t.Fatalf("row %s came back on two pages", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
next, _ := body["next_cursor"].(string)
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
if next == cursor {
|
||||
t.Fatalf("the cursor did not move: %s", next)
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
|
||||
// Everything that was written is in the walk exactly once: the task creations,
|
||||
// plus the project creation.
|
||||
if len(seen) != written+1 {
|
||||
t.Errorf("the walk saw %d rows, want %d — a page boundary lost some",
|
||||
len(seen), written+1)
|
||||
}
|
||||
}
|
||||
|
||||
// The filters, and the list the event filter is drawn from.
|
||||
func TestTheAuditLogFiltersAndListsItsEvents(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
mineID := mine["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min", "project_id": mineID})
|
||||
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
|
||||
theirID := theirs["id"].(string)
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "deres", "project_id": theirID})
|
||||
|
||||
_, byUser := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID, nil)
|
||||
for _, raw := range byUser["activity"].([]any) {
|
||||
if got := raw.(map[string]any)["user_id"]; got != otherUser.ID {
|
||||
t.Errorf("user_id filter returned a row by %v", got)
|
||||
}
|
||||
}
|
||||
if len(byUser["activity"].([]any)) == 0 {
|
||||
t.Error("the user filter returned nothing")
|
||||
}
|
||||
|
||||
_, byProject := ts.do(t, "GET", "/api/v1/activity?project_id="+mineID, nil)
|
||||
for _, raw := range byProject["activity"].([]any) {
|
||||
if got := raw.(map[string]any)["project_id"]; got != mineID {
|
||||
t.Errorf("project_id filter returned a row from %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
_, byEvent := ts.do(t, "GET", "/api/v1/activity?event=project.created", nil)
|
||||
rows := byEvent["activity"].([]any)
|
||||
if len(rows) != 2 {
|
||||
t.Errorf("event filter found %d project.created rows, want 2", len(rows))
|
||||
}
|
||||
for _, raw := range rows {
|
||||
if got := raw.(map[string]any)["event"]; got != "project.created" {
|
||||
t.Errorf("event filter returned %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// And the vocabulary the filter is built from.
|
||||
_, events := ts.do(t, "GET", "/api/v1/activity/events", nil)
|
||||
counts := map[string]float64{}
|
||||
for _, raw := range events["events"].([]any) {
|
||||
e := raw.(map[string]any)
|
||||
counts[e["event"].(string)] = e["count"].(float64)
|
||||
}
|
||||
if counts["project.created"] != 2 || counts["task.created"] != 2 {
|
||||
t.Errorf("event counts = %v", counts)
|
||||
}
|
||||
|
||||
// A malformed cursor is a 400, not a silent first page: paging that quietly
|
||||
// restarts is how an audit walk reads the same rows forever.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/activity?before=igår", nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a malformed cursor: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted account leaves its trail behind. This is the reason migration 0009
|
||||
// exists: an audit log that lost its rows when somebody left would be missing
|
||||
// exactly the part worth keeping.
|
||||
func TestTheAuditLogOutlivesTheAccountItDescribes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// In a project that outlives them, so the row is not carried off by the
|
||||
// project cascade instead.
|
||||
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||
sharedID := mine["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "editor",
|
||||
})
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "deres bidrag", "project_id": sharedID,
|
||||
})
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID+"&event=task.created", nil)
|
||||
if len(before["activity"].([]any)) != 1 {
|
||||
t.Fatalf("expected one row before the delete: %v", before["activity"])
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/activity?project_id="+sharedID+"&event=task.created", nil)
|
||||
rows := after["activity"].([]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("the row went with the account: %v", rows)
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if _, ok := row["user_id"]; ok {
|
||||
t.Errorf("user_id is still there after the account went: %v", row["user_id"])
|
||||
}
|
||||
if _, ok := row["user_name"]; ok {
|
||||
t.Errorf("user_name is still there after the account went: %v", row["user_name"])
|
||||
}
|
||||
|
||||
// And the project's own history still shows it, rather than dropping the row
|
||||
// on an inner join to a user that is gone.
|
||||
_, project := ts.do(t, "GET", "/api/v1/projects/"+sharedID+"/activity", nil)
|
||||
var kept int
|
||||
for _, raw := range project["activity"].([]any) {
|
||||
if raw.(map[string]any)["event"] == "task.created" {
|
||||
kept++
|
||||
}
|
||||
}
|
||||
if kept != 1 {
|
||||
t.Errorf("the project history dropped the authorless row: %d", kept)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
@@ -25,17 +27,91 @@ type userJSON struct {
|
||||
Locale string `json:"locale"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// SidebarCollapsed is which sidebar headings this person has folded away. On
|
||||
// the account rather than in the browser, because folding one is a statement
|
||||
// about the work and not about the screen — the sidebar's width is the
|
||||
// opposite case and stays in localStorage.
|
||||
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||
NavOrder []string `json:"nav_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toUserJSON(u *store.User) userJSON {
|
||||
collapsed := u.SidebarCollapsed
|
||||
// Never null in the response: a client that has to check for both an absent
|
||||
// array and an empty one will one day check for only one of them.
|
||||
if collapsed == nil {
|
||||
collapsed = []string{}
|
||||
}
|
||||
order := u.NavOrder
|
||||
if order == nil {
|
||||
order = []string{}
|
||||
}
|
||||
return userJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||
TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetSidebarSections records which sidebar headings are folded away.
|
||||
//
|
||||
// Its own route rather than a field on the profile: that one backs a form with a
|
||||
// save button and validates three fields together, and this writes on every click
|
||||
// of a chevron. Sharing the path would mean a fold sending a name and a timezone
|
||||
// it was never asked to change — and a validation error on one of those failing a
|
||||
// fold.
|
||||
//
|
||||
// The keys are the sidebar's and are not checked here. An unknown one costs
|
||||
// nothing: the sidebar folds the headings it recognises and ignores the rest, so
|
||||
// a heading that is renamed or removed leaves a harmless entry rather than a
|
||||
// migration.
|
||||
func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Sections []string `json:"sections"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// A cap, because this is a list the client decides the length of. Twenty is far
|
||||
// more headings than the sidebar has ever had.
|
||||
if len(req.Sections) > 20 {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many sections")
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetSidebarCollapsed(r.Context(), user.ID, req.Sections); err != nil {
|
||||
s.storeError(w, r, "set sidebar sections", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetNavOrder records the order of the fixed views in the sidebar.
|
||||
//
|
||||
// Its own route for the same reason as the folds: this writes on every drop, and
|
||||
// sharing the profile's path would make a reorder send a name and a timezone it
|
||||
// was never asked to change.
|
||||
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Order []string `json:"order"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.Order) > 20 {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
|
||||
s.storeError(w, r, "set nav order", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -78,7 +154,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
// The bucket is cleared only when the login is *finished*.
|
||||
//
|
||||
// It used to be cleared here, on the strength of the password alone — before
|
||||
// the second factor had been asked for at all. That handed somebody who
|
||||
// already had the password an unlimited supply of ten-guess batches: log in,
|
||||
// get a fresh bucket and a fresh pending session, spend ten guesses on the
|
||||
// code, log in again. At three valid codes in any moment and twenty requests a
|
||||
// second, that is even odds inside an afternoon — against the one control that
|
||||
// exists precisely for when the password is already gone.
|
||||
//
|
||||
// So an account with a second factor clears nothing yet. handleLoginTOTP does
|
||||
// it, once the code is right.
|
||||
if !user.TOTPEnabled {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
}
|
||||
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
@@ -88,7 +178,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
@@ -115,6 +205,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
session := sessionFrom(r.Context())
|
||||
|
||||
// Counted per account as well as per address.
|
||||
//
|
||||
// The address bucket alone is defeated by anybody with more than one, which is
|
||||
// the ordinary case — a phone and a laptop is already two. A code is six
|
||||
// digits: the only thing that makes it safe is that guesses are scarce, and
|
||||
// scarcity has to be counted against the thing being attacked.
|
||||
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
|
||||
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
||||
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"too many verification codes; wait a moment and try again")
|
||||
return
|
||||
}
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
@@ -122,6 +225,15 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
// introduce a second concept.
|
||||
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
||||
if rerr != nil || !used {
|
||||
// The pending session is left alive on purpose.
|
||||
//
|
||||
// Ending it on the first wrong digit was considered and rejected: it
|
||||
// would make somebody who mistyped one number type their password again,
|
||||
// and it buys almost nothing. What makes a six-digit code safe is that
|
||||
// guesses are scarce, and the per-account bucket above is what makes
|
||||
// them scarce — ten an hour, however many times the password is
|
||||
// re-entered. The attack this closes was never "one session, many
|
||||
// guesses"; it was "a fresh bucket for every password check".
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code is not valid")
|
||||
return
|
||||
@@ -129,8 +241,13 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("recovery code used", "user", user.ID)
|
||||
}
|
||||
|
||||
// Finished. Now the buckets may be cleared — both of them, so somebody who
|
||||
// fumbled the code twice is not still waiting once they get it right.
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, "promote session", err)
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
// The cookie is reissued so its lifetime matches the session's new one.
|
||||
@@ -210,13 +327,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
|
||||
s.storeError(w, "update profile", err)
|
||||
s.storeError(w, r, "update profile", err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.db.UserByID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "reload user", err)
|
||||
s.internal(w, r, "reload user", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
||||
@@ -263,7 +380,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,14 +394,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
"an account already exists for that email address")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create user", err)
|
||||
s.internal(w, r, "create user", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Accepting the invite is what adds the membership and burns the token, in one
|
||||
// transaction: a signup that half-succeeded would leave a usable invite behind.
|
||||
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
|
||||
s.internal(w, "accept invite", err)
|
||||
s.internal(w, r, "accept invite", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -328,7 +445,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -341,7 +458,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create first admin", err)
|
||||
s.internal(w, r, "create first admin", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,7 +472,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.UserCount(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "count users", err)
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
@@ -364,7 +481,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
||||
@@ -393,7 +510,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create password reset", err)
|
||||
s.internal(w, r, "create password reset", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/reset?token=" + token
|
||||
@@ -431,13 +548,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
// Every session ends: a password reset is what somebody does when they think
|
||||
// another person has been in their account.
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
s.internal(w, r, "update password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -469,7 +586,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
keep := ""
|
||||
@@ -477,7 +594,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
keep = session.ID
|
||||
}
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
s.internal(w, r, "update password", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -520,7 +637,64 @@ func inboxName(locale string) string {
|
||||
// internal logs the real error and tells the client nothing about it. The detail
|
||||
// belongs in the log, where the operator can see it; in the response it would be a
|
||||
// description of the server's internals handed to whoever asked.
|
||||
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
|
||||
//
|
||||
// It is also written to the database. On a Rune the log is the container's, and
|
||||
// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at
|
||||
// 11:49" long after the line explaining it has gone, which tells an operator that
|
||||
// something broke and gives them no way to find out what. The row outlives the
|
||||
// container; Indstillinger → Fejl is where it is read.
|
||||
// upstream reports a failure that came from somebody else's service.
|
||||
//
|
||||
// Separate from `internal` because the cause is different and so is the fix: a 500
|
||||
// is this server broken, and this is Google — or whoever — saying no. The status
|
||||
// says so (502), and the message is passed through rather than replaced, because
|
||||
// "invalid_grant" or "insufficient authentication scopes" is the whole diagnosis
|
||||
// and a generic sentence in its place throws it away.
|
||||
//
|
||||
// It is recorded like a 500 for exactly the reason the error log exists: a Gmail
|
||||
// sync that has been failing for a week is invisible otherwise. The background job
|
||||
// writes a log line the next restart erases, and nobody is watching the one screen
|
||||
// that would have said so.
|
||||
// StatusUpstreamRefused is what this server answers when a service it depends on
|
||||
// says no. Not 502, though that is what it means: a proxy is entitled to replace
|
||||
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
|
||||
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
|
||||
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
|
||||
// "something went wrong". A 4xx passes through untouched, and the truth reaching
|
||||
// the person who has to act on it beats the tidier status code.
|
||||
const StatusUpstreamRefused = http.StatusUnprocessableEntity
|
||||
|
||||
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
|
||||
s.log.Warn(what, "err", err)
|
||||
|
||||
userID := ""
|
||||
if u := userFrom(r.Context()); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
s.db.RecordError(r.Context(), store.ServerError{
|
||||
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
|
||||
What: what, Message: err.Error(), UserID: userID,
|
||||
RequestID: middleware.GetReqID(r.Context()),
|
||||
})
|
||||
|
||||
writeError(w, StatusUpstreamRefused, code, err.Error())
|
||||
}
|
||||
|
||||
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
s.log.Error(what, "err", err)
|
||||
|
||||
userID := ""
|
||||
if u := userFrom(r.Context()); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
// Best effort, and deliberately not checked: this is the path that is already
|
||||
// reporting a failure, and a diagnostic that can fail the request would turn
|
||||
// one broken screen into two.
|
||||
s.db.RecordError(r.Context(), store.ServerError{
|
||||
Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError,
|
||||
What: what, Message: err.Error(), UserID: userID,
|
||||
RequestID: middleware.GetReqID(r.Context()),
|
||||
})
|
||||
|
||||
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://127.0.0.1",
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
@@ -299,7 +301,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
|
||||
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
||||
// not lock the account.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
|
||||
// at slå den fra, og den har altid spurgt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
@@ -366,7 +372,9 @@ func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
@@ -895,3 +903,65 @@ func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Andenfaktoren kan ikke slås til uden adgangskoden.
|
||||
//
|
||||
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
|
||||
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
|
||||
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
|
||||
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
|
||||
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
|
||||
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "det forkerte kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("en tom adgangskode blev accepteret")
|
||||
}
|
||||
}
|
||||
|
||||
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
|
||||
//
|
||||
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
|
||||
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
|
||||
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
|
||||
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
|
||||
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
|
||||
// her kunne blive ved i det uendelige.
|
||||
refused := 0
|
||||
for i := 0; i < 14; i++ {
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
continue
|
||||
}
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused == 0 {
|
||||
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/jobs"
|
||||
)
|
||||
|
||||
// The nightly backup, made visible.
|
||||
//
|
||||
// It has run since the beginning and nothing showed it. A backup nobody can see is
|
||||
// a backup nobody knows has been failing — and the failure mode is quiet: a volume
|
||||
// that went read-only, a disk that filled, a container that has not been up at
|
||||
// midnight for a week. The first time anybody finds out is the day they need one.
|
||||
//
|
||||
// Administrators only, and behind a session as well as the admin check. A backup
|
||||
// file is a complete copy of the database, so downloading one is downloading
|
||||
// everybody's data; a leaked API token must not be able to.
|
||||
|
||||
type backupRunJSON struct {
|
||||
ID string `json:"id"`
|
||||
Started string `json:"started_at"`
|
||||
// Finished is absent while a backup is still running, and on one that was
|
||||
// interrupted — which is the same shape from outside and honestly so: a run
|
||||
// the process died in the middle of never finished.
|
||||
Finished string `json:"finished_at,omitempty"`
|
||||
Size int64 `json:"size_bytes"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Present says the file is still on disk. Rotation keeps the most recent
|
||||
// fourteen, so an older row is a true record of a backup that was made and a
|
||||
// file that is no longer there — and a download link for it would 404.
|
||||
Present bool `json:"present"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.db.ListBackupRuns(r.Context(), parseLimit(r.URL.Query().Get("limit"), 30, 100))
|
||||
if err != nil {
|
||||
s.internal(w, r, "list backups", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]backupRunJSON, 0, len(runs))
|
||||
for _, b := range runs {
|
||||
j := backupRunJSON{
|
||||
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
||||
Size: b.SizeBytes, Error: b.Error,
|
||||
}
|
||||
if b.FinishedAt != nil {
|
||||
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
||||
}
|
||||
if b.Path != "" {
|
||||
if _, err := os.Stat(b.Path); err == nil {
|
||||
j.Present = true
|
||||
}
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"backups": out})
|
||||
}
|
||||
|
||||
// handleRunBackup takes one now.
|
||||
//
|
||||
// The same function the nightly job runs, without the "has one run today" check.
|
||||
// Waiting until tonight to learn whether backups work at all is how somebody finds
|
||||
// out on the day they need one — and it is the only way to get a snapshot from
|
||||
// *before* something you are about to do.
|
||||
//
|
||||
// Synchronous. A backup of a homelab database is a second or two, and a button
|
||||
// that returns immediately and tells you nothing is a button you press twice.
|
||||
func (s *Server) handleRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := jobs.Backup(r.Context(), s.cfg, s.db, s.log); err != nil {
|
||||
s.internal(w, r, "run backup", err)
|
||||
return
|
||||
}
|
||||
runs, err := s.db.ListBackupRuns(r.Context(), 1)
|
||||
if err != nil || len(runs) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
b := runs[0]
|
||||
j := backupRunJSON{
|
||||
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
||||
Size: b.SizeBytes, Error: b.Error, Present: true,
|
||||
}
|
||||
if b.FinishedAt != nil {
|
||||
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, j)
|
||||
}
|
||||
|
||||
// handleDownloadBackup sends one backup file.
|
||||
//
|
||||
// The path comes from the row rather than from the request, and is then checked to
|
||||
// be inside the backups directory anyway. The row is written by this process and
|
||||
// should always be safe; the check is there because "should always" is how a path
|
||||
// traversal gets shipped, and the thing on the other side of it is every account's
|
||||
// data in one file.
|
||||
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.db.ListBackupRuns(r.Context(), 100)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list backups", err)
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "backupID")
|
||||
var path string
|
||||
for _, b := range runs {
|
||||
if b.ID == id {
|
||||
path = b.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := filepath.Abs(s.cfg.BackupsDir())
|
||||
if err != nil {
|
||||
s.internal(w, r, "backups dir", err)
|
||||
return
|
||||
}
|
||||
full, err := filepath.Abs(path)
|
||||
if err != nil || !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
file, err := os.Open(full)
|
||||
if err != nil {
|
||||
// Rotation keeps the most recent fourteen, so a row older than that is a
|
||||
// true record with no file behind it. Not an error — a 404 is the honest
|
||||
// answer to "send me that one".
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
s.internal(w, r, "stat backup", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The same treatment attachments get, and for a stronger reason: this is a
|
||||
// SQLite database, and nothing should ever be tempted to render it inline.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf("attachment; filename=%q", filepath.Base(full)))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeContent(w, r, "", info.ModTime(), file)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The backup panel exists because the job was invisible, so the thing to check is
|
||||
// that it can be seen and asked for — and that the file it hands out is guarded
|
||||
// like what it is, which is every account's data in one download.
|
||||
func TestABackupCanBeTakenListedAndDownloaded(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Nothing yet, and that is a state the list has to survive rather than a case
|
||||
// nobody hits: it is what a fresh instance looks like.
|
||||
_, empty := ts.do(t, "GET", "/api/v1/backups", nil)
|
||||
if got, _ := empty["backups"].([]any); len(got) != 0 {
|
||||
t.Fatalf("a fresh instance has %d backups", len(got))
|
||||
}
|
||||
|
||||
resp, made := ts.do(t, "POST", "/api/v1/backups", nil)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("run backup: status %d, body %v", resp.StatusCode, made)
|
||||
}
|
||||
id, _ := made["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("the new run has no id")
|
||||
}
|
||||
if size, _ := made["size_bytes"].(float64); size <= 0 {
|
||||
t.Errorf("size_bytes = %v — a backup of a database with rows in it is not empty", made["size_bytes"])
|
||||
}
|
||||
if made["error"] != nil {
|
||||
t.Errorf("the backup reported an error: %v", made["error"])
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/backups", nil)
|
||||
rows, _ := listed["backups"].([]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("listed %d backups, want 1", len(rows))
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["id"] != id {
|
||||
t.Errorf("the listed run is not the one that was made")
|
||||
}
|
||||
if row["present"] != true {
|
||||
t.Error("present = false on a backup that was just written")
|
||||
}
|
||||
|
||||
// And the file itself, which must arrive as a download rather than as
|
||||
// something a browser might try to render.
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/backups/"+id, nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
dl, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dl.Body.Close()
|
||||
if dl.StatusCode != http.StatusOK {
|
||||
t.Fatalf("download: status %d", dl.StatusCode)
|
||||
}
|
||||
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Errorf("Content-Type = %q, want application/octet-stream", ct)
|
||||
}
|
||||
if cd := dl.Header.Get("Content-Disposition"); cd == "" || cd[:10] != "attachment" {
|
||||
t.Errorf("Content-Disposition = %q, want an attachment", cd)
|
||||
}
|
||||
|
||||
// An id that is not a backup is a 404, not a path to try opening.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/backups/../../etc/passwd", nil); resp.StatusCode == http.StatusOK {
|
||||
t.Error("a traversal in the id was served")
|
||||
}
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/backups/nogetsomhelst", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an unknown id: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Administrators only, and sessions only. A backup is a complete copy of the
|
||||
// database — a leaked token that could download one turns a stolen credential into
|
||||
// a copy of everybody's data.
|
||||
func TestBackupsAreAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/backups"},
|
||||
{"POST", "/api/v1/backups"},
|
||||
{"GET", "/api/v1/backups/whichever"},
|
||||
} {
|
||||
if resp, _ := other.do(t, c.method, c.path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s as an ordinary user: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/backups"},
|
||||
{"POST", "/api/v1/backups"},
|
||||
} {
|
||||
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s with an admin's API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/beacon"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The beacon, from the instance's side.
|
||||
//
|
||||
// Two halves that happen to live in the same binary: the part that reports in,
|
||||
// which every installation runs, and the part that receives, which only the one
|
||||
// acting as collector turns on. See internal/beacon for what is sent and why it
|
||||
// is on by default rather than compulsory.
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
type beaconConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
// anonymous id on first use.
|
||||
//
|
||||
// The id is generated here rather than derived from anything about the machine.
|
||||
// A hash of the hostname or the base URL would be stable and convenient and would
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
if v, ok := stored["enabled"].(bool); ok {
|
||||
cfg.Enabled = v
|
||||
}
|
||||
if v, ok := stored["collector_url"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
cfg.Collector = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := stored["instance_id"].(string); ok && beacon.ValidID(v) {
|
||||
cfg.InstanceID = v
|
||||
}
|
||||
if v, ok := stored["is_collector"].(bool); ok {
|
||||
cfg.IsCollector = v
|
||||
}
|
||||
if v, ok := stored["publish_count"].(bool); ok {
|
||||
cfg.PublishCount = v
|
||||
}
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
if err := s.saveBeaconSettings(ctx, cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error {
|
||||
return s.db.SetInstanceSettings(ctx, beaconScope, map[string]any{
|
||||
"enabled": cfg.Enabled,
|
||||
"collector_url": cfg.Collector,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon summary", err)
|
||||
return
|
||||
}
|
||||
out.Stats = &stats
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type beaconRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
if req.IsCollector != nil {
|
||||
cfg.IsCollector = *req.IsCollector
|
||||
}
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
url = beacon.DefaultCollector
|
||||
}
|
||||
// http and https only, and said as a field error rather than a 500: an
|
||||
// operator pointing this at their own collector will mistype it, and the
|
||||
// page should say which field is wrong.
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"collector_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
cfg.Collector = url
|
||||
}
|
||||
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.handleBeaconStatus(w, r)
|
||||
}
|
||||
|
||||
// --- the collector's side ---------------------------------------------------------
|
||||
|
||||
// handleBeaconPing receives one report. Unauthenticated, because the whole point
|
||||
// is that a stranger's installation can reach it.
|
||||
//
|
||||
// Nothing about the request is recorded except the two values in the body. The
|
||||
// source address is not read here, and there is no code path from this function
|
||||
// to anything that could write one down — which is the only version of that
|
||||
// promise worth making.
|
||||
func (s *Server) handleBeaconPing(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
// An instance that is not a collector does not quietly accept and drop pings —
|
||||
// it says there is nothing here. Otherwise every installation in the world is a
|
||||
// spam sink that answers 200 to anything.
|
||||
if !cfg.IsCollector {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "this instance is not a beacon collector")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
|
||||
var p beacon.Payload
|
||||
if err := decodeJSON(w, r, &p); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Checked, not trusted. This is the one endpoint anybody can post to, and the
|
||||
// two columns behind it should only ever hold the two shapes this program
|
||||
// writes — an id that is not id-shaped is somebody probing, not an install.
|
||||
if !beacon.ValidID(p.InstanceID) {
|
||||
writeFieldErrors(w, map[string]string{"instance_id": "malformed"})
|
||||
return
|
||||
}
|
||||
if p.Version != "" && !beacon.ValidVersion(p.Version) {
|
||||
writeFieldErrors(w, map[string]string{"version": "malformed"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.RecordBeacon(r.Context(), p.InstanceID, p.Version); err != nil {
|
||||
s.internal(w, r, "record beacon", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleBeaconCount publishes the number, when the collector has been told to.
|
||||
//
|
||||
// Thirty days rather than a running total: a total only climbs, so it stops
|
||||
// meaning "how many people use this" and starts meaning "how many ever tried it".
|
||||
func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.IsCollector || !cfg.PublishCount {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not published")
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": n, "window_days": 30})
|
||||
}
|
||||
|
||||
// SendBeacon reports this installation, at most once a day.
|
||||
//
|
||||
// Exported and handed to the jobs runner the same way the mailbox syncs are: the
|
||||
// settings, the anonymous id and the version all live on this side, and the
|
||||
// runner should not have to know about any of them.
|
||||
//
|
||||
// The day is counted from the last successful send, not from a wall-clock hour.
|
||||
// A fleet of installations that all pinged at 03:00 would arrive in one spike;
|
||||
// counted this way they spread themselves out by when each was started.
|
||||
func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
cfg, err := s.beaconSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
// The collector does not report to itself over the network. It would work, and
|
||||
// it would also mean the one instance whose count matters most depends on its
|
||||
// own tunnel being up to be counted.
|
||||
if cfg.IsCollector {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
|
||||
// safedial, ikke http.DefaultClient. Adressen kan skrives i fladen, og en
|
||||
// administrator, der peger den på 127.0.0.1 eller på 169.254.169.254, ville
|
||||
// ellers have en maskine, der henter indefra på kommando — og svaret på om den
|
||||
// svarede, er i sig selv det, sådan et forsøg leder efter.
|
||||
if err := beacon.Send(ctx, safedial.Client(15*time.Second), cfg.Collector, beacon.Payload{
|
||||
InstanceID: cfg.InstanceID,
|
||||
Version: Version,
|
||||
}); err != nil {
|
||||
// Logged at info, not error. The collector being unreachable is not this
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav home", err)
|
||||
s.internal(w, r, "caldav home", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Depth") != "0" {
|
||||
tasks, err := s.caldavTasks(r, user, projectID)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav tasks", err)
|
||||
s.internal(w, r, "caldav tasks", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
@@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.caldavTasks(r, user, projectID)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav report", err)
|
||||
s.internal(w, r, "caldav report", err)
|
||||
return
|
||||
}
|
||||
byID := map[string]store.Task{}
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
update.DueDatetime = parsed.DueDatetime
|
||||
|
||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
|
||||
s.internal(w, "caldav update", err)
|
||||
s.internal(w, r, "caldav update", err)
|
||||
return
|
||||
}
|
||||
if parsed.Completed && !existing.Completed() {
|
||||
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
task.Content = "(uden titel)"
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
|
||||
s.internal(w, "caldav create", err)
|
||||
s.internal(w, r, "caldav create", err)
|
||||
return
|
||||
}
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||
s.internal(w, "caldav delete", err)
|
||||
s.internal(w, r, "caldav delete", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/gcal"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// defaultCalendarSyncBudget bounds one "fetch now" when nothing else says
|
||||
// otherwise. Well under the hundred seconds a proxy is willing to wait, so this
|
||||
// server is the one that answers a slow Google rather than Cloudflare's own HTML
|
||||
// page — which is how a failure arrives with no code, no message and nothing in
|
||||
// the error log.
|
||||
const defaultCalendarSyncBudget = 25 * time.Second
|
||||
|
||||
// The window verdande keeps a copy of.
|
||||
//
|
||||
// A month grid pages, so asking Google on every arrow press would mean a round trip
|
||||
// for a question answered ninety seconds ago — a calendar slower than the calendar
|
||||
// it is showing. A cached window is the alternative, and a window has to end
|
||||
// somewhere. Ninety days back is roughly "when was that", a year forward is past
|
||||
// anything anybody has booked; between them they cover every screen the grid can
|
||||
// reach without a person noticing an edge.
|
||||
//
|
||||
// The edge is still real, and the events endpoint says where it is rather than
|
||||
// answering an empty list for a month outside it. A calendar that quietly shows no
|
||||
// events is a calendar that lies about a day being clear.
|
||||
const (
|
||||
calendarWindowBack = 90
|
||||
calendarWindowAhead = 365
|
||||
calendarDateLayout = "2006-01-02"
|
||||
calendarProviderName = "google"
|
||||
)
|
||||
|
||||
func (s *Server) calendarClient(accessToken string) *gcal.Client {
|
||||
return gcal.NewClient(accessToken).At(s.cfg.CalendarAPIURL)
|
||||
}
|
||||
|
||||
// calendarConfig is the same registration Gmail signs in through, asking for a
|
||||
// different scope and coming back to a different URI.
|
||||
//
|
||||
// One OAuth client, two features. Google issues a refresh token per authorisation
|
||||
// and keeps up to a hundred of them live per account, each carrying the scopes it
|
||||
// was granted with — so connecting the calendar does not disturb a working Gmail,
|
||||
// and disconnecting one does not take the other with it.
|
||||
func (s *Server) calendarConfig(ctx context.Context) gcal.Config {
|
||||
cfg := s.gmailConfig(ctx)
|
||||
cfg.Scope = gcal.Scope
|
||||
cfg.RedirectURL = s.cfg.CalendarRedirectURL()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// --- connecting ------------------------------------------------------------------
|
||||
|
||||
// handleCalendarAuthorize starts the flow. Same shape as Gmail's, and the same
|
||||
// reason for keeping the verifier server-side: the callback arrives as a top-level
|
||||
// navigation from Google, and a SameSite=Lax cookie is the kind of thing that works
|
||||
// until a browser tightens its defaults.
|
||||
func (s *Server) handleCalendarAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
cfg := s.calendarConfig(r.Context())
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
"no Google OAuth client is registered on this server")
|
||||
return
|
||||
}
|
||||
|
||||
pkce, err := gcal.NewPKCE()
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar pkce", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Its own settings scope, not Gmail's. Two handshakes in one row would mean a
|
||||
// person who started both flows had one verifier, and whichever came back
|
||||
// second would be refused for a reason nothing on the screen could explain.
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
settings["pkce_verifier"] = pkce.Verifier
|
||||
settings["pkce_state"] = pkce.State
|
||||
// The attempt expires: a stored verifier that lives forever is a replay window
|
||||
// that lives forever.
|
||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||
}
|
||||
|
||||
// handleCalendarCallback is where Google sends the browser back.
|
||||
//
|
||||
// It answers with a redirect into the app rather than with JSON: the person is
|
||||
// looking at a browser tab, not at a fetch response.
|
||||
func (s *Server) handleCalendarCallback(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
settingsURL := s.cfg.BaseURL + "/indstillinger/integrationer"
|
||||
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
// Somebody clicked "cancel" on the consent screen — or, on an Internal
|
||||
// OAuth app, Google refused an account outside the organisation with
|
||||
// `org_internal`. Both arrive here, and the page names what it was given.
|
||||
http.Redirect(w, r, settingsURL+"?calendar="+errParam, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
if code == "" || state == "" {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=invalid", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
verifier, _ := settings["pkce_verifier"].(string)
|
||||
expected, _ := settings["pkce_state"].(string)
|
||||
expires, _ := settings["pkce_expires"].(float64)
|
||||
|
||||
// The state has to match, or this endpoint accepts a code from anywhere.
|
||||
if verifier == "" || expected == "" || state != expected {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=state", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if int64(expires) < time.Now().Unix() {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=expired", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.calendarConfig(r.Context()).Exchange(r.Context(), code,
|
||||
gcal.PKCE{Verifier: verifier})
|
||||
if err != nil {
|
||||
s.log.Error("calendar exchange", "err", err, "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
// Without one the connection dies in an hour. Google only withholds it when
|
||||
// the consent prompt was skipped, which access_type and prompt are meant to
|
||||
// prevent — so this means something is wrong with the client registration
|
||||
// rather than with this request.
|
||||
s.log.Error("calendar returned no refresh token", "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=norefresh", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// The one-time values are cleared: keeping a spent verifier serves nothing and
|
||||
// is one more secret at rest.
|
||||
delete(settings, "pkce_verifier")
|
||||
delete(settings, "pkce_state")
|
||||
delete(settings, "pkce_expires")
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "clear calendar handshake", err)
|
||||
return
|
||||
}
|
||||
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: calendarProviderName,
|
||||
RefreshToken: token.RefreshToken, AccessToken: token.AccessToken,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
}
|
||||
if err := s.db.SaveCalendarAccount(r.Context(), account); err != nil {
|
||||
s.internal(w, r, "save calendar tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The list of calendars, fetched now so the page that opens next has something
|
||||
// to tick rather than an empty panel and a button. Best effort: an account that
|
||||
// connects but cannot list itself is still connected, and the status endpoint
|
||||
// asks again when it finds nothing.
|
||||
if _, err := s.refreshCalendarList(r.Context(), account); err != nil {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
|
||||
s.log.Info("calendar connected", "user", user.ID, "account", account.Account)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=connected", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- what the settings page reads --------------------------------------------------
|
||||
|
||||
type calendarStatusJSON struct {
|
||||
Connected bool `json:"connected"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Calendars []store.Calendar `json:"calendars"`
|
||||
// RedirectURI is what has to be registered with Google, spelled out. Derived
|
||||
// from VERDANDE_BASE_URL and matched exactly — the single most likely thing to
|
||||
// be wrong, and the error Google gives for it names neither value.
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
// HasClient says whether there is an OAuth registration to connect through at
|
||||
// all, so the page can explain rather than offer a button that answers 409.
|
||||
HasClient bool `json:"has_client"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
||||
// an interface that quietly looks editable is one somebody will try to edit.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
out := calendarStatusJSON{
|
||||
Calendars: []store.Calendar{},
|
||||
RedirectURI: s.cfg.CalendarRedirectURL(),
|
||||
HasClient: s.calendarConfig(r.Context()).Configured(),
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
out.Connected = true
|
||||
out.Account = account.Account
|
||||
if !account.LastSyncAt.IsZero() {
|
||||
out.LastSyncAt = account.LastSyncAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
calendars, err := s.db.Calendars(r.Context(), account.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendars", err)
|
||||
return
|
||||
}
|
||||
// An account connected during a Google hiccup has no calendars and no way back:
|
||||
// the person sees an empty list and nothing to press. Asking again here costs
|
||||
// one request on a page nobody opens often, and means the wrong answer lasts
|
||||
// until the next look rather than for ever. Same reasoning as the Gmail address.
|
||||
if len(calendars) == 0 {
|
||||
if fetched, err := s.refreshCalendarList(r.Context(), account); err == nil {
|
||||
calendars = fetched
|
||||
out.Account = account.Account
|
||||
} else {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
if calendars != nil {
|
||||
out.Calendars = calendars
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type showCalendarsRequest struct {
|
||||
// Shown is the whole set, not one flip. Same reasoning as the projects' order:
|
||||
// a person has a handful of calendars, the question is "these ones", and a list
|
||||
// that can only be built by several requests can land half applied.
|
||||
Shown []string `json:"shown"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
var req showCalendarsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
// A 404 rather than a 409: everything the caller may not see answers 404,
|
||||
// and "there is no such connection" is the true answer either way.
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no calendar is connected")
|
||||
return
|
||||
}
|
||||
if err := s.db.ShowCalendars(r.Context(), account.ID, req.Shown); err != nil {
|
||||
s.internal(w, r, "show calendars", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDisconnectCalendar forgets the connection, and the copy of the calendars
|
||||
// with it. Unlike the tasks a mailbox made, none of this is the person's own work.
|
||||
func (s *Server) handleDisconnectCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, account.ID); err != nil {
|
||||
s.internal(w, r, "disconnect calendar", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- what the grid reads -----------------------------------------------------------
|
||||
|
||||
type calendarEventsJSON struct {
|
||||
Events []store.CalendarEvent `json:"events"`
|
||||
// From and To are the window verdande holds a copy of, not the window that was
|
||||
// asked for. A grid that has paged past it gets no events, and an empty answer
|
||||
// with no explanation is a calendar quietly claiming a day is clear.
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
|
||||
fields := map[string]string{}
|
||||
if !validDay(from) {
|
||||
fields["from"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if !validDay(to) {
|
||||
fields["to"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
events, err := s.db.CalendarEvents(r.Context(), user.ID, from, to)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar events", err)
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []store.CalendarEvent{}
|
||||
}
|
||||
windowFrom, windowTo := calendarWindow(time.Now())
|
||||
writeJSON(w, http.StatusOK, calendarEventsJSON{
|
||||
Events: events, From: windowFrom, To: windowTo,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCalendarSyncNow refreshes immediately, so somebody who has just connected
|
||||
// can see it work rather than wondering whether it did.
|
||||
func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// A budget of its own, well under what sits in front of this server. An unset
|
||||
// budget is not a budget of nothing: a zero here would expire the context
|
||||
// before the first call and make every sync a silent no-op.
|
||||
budget := s.cfg.CalendarSyncBudget
|
||||
if budget <= 0 {
|
||||
budget = defaultCalendarSyncBudget
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||
defer cancel()
|
||||
|
||||
count, err := s.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Not a failure: Google is slow or busy, some calendars were read, and
|
||||
// the rest arrive on the next run. Every calendar is committed as it is
|
||||
// read, so a short budget is a shorter run rather than a lost one.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": count, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeCalendarFailed, "sync calendar", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"events": count})
|
||||
}
|
||||
|
||||
// SyncCalendars refreshes every calendar this person has chosen to see.
|
||||
//
|
||||
// Returns how many events were written. Also used by the background job, which is
|
||||
// why it takes a context and a user rather than a request.
|
||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
||||
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
||||
}
|
||||
|
||||
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
||||
// now" during the sweep would otherwise have two runs replacing the same window
|
||||
// at once, and the loser's delete would land after the winner's insert.
|
||||
unlock := s.lockSync("calendar:" + account.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName); err == nil && fresh != nil {
|
||||
account = fresh
|
||||
}
|
||||
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
client := s.calendarClient(access)
|
||||
|
||||
// The list first, so a calendar added or renamed since the last run is drawn
|
||||
// under its new name and one that has gone stops being drawn at all.
|
||||
calendars, err := s.calendarList(ctx, client, account)
|
||||
if err != nil {
|
||||
return 0, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
for _, calendar := range calendars {
|
||||
if !calendar.Shown {
|
||||
continue
|
||||
}
|
||||
// A spent budget ends the run rather than turning into a failed fetch per
|
||||
// calendar, logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return written, ctx.Err()
|
||||
}
|
||||
events, err := client.Events(ctx, calendar.RemoteID, from, to)
|
||||
if err != nil {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
return written, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
// One calendar somebody was unshared from must not stop the others.
|
||||
s.log.Warn("calendar events", "err", err, "calendar", calendar.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
rows := make([]store.CalendarEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
rows = append(rows, store.CalendarEvent{
|
||||
RemoteID: e.ID, Summary: e.Summary,
|
||||
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||
StartDay: e.StartDay, EndDay: e.EndDay,
|
||||
AllDay: e.AllDay, Location: e.Location, URL: e.URL,
|
||||
})
|
||||
}
|
||||
if err := s.db.ReplaceCalendarEvents(ctx, calendar.ID, from, to, rows); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += len(rows)
|
||||
}
|
||||
|
||||
if err := s.db.MarkCalendarSynced(ctx, account.ID, time.Now()); err != nil {
|
||||
return written, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// calendarAccessToken returns a usable access token, refreshing only when the one
|
||||
// held has actually expired.
|
||||
func (s *Server) calendarAccessToken(ctx context.Context, account *store.CalendarAccount) (string, error) {
|
||||
if account.AccessToken != "" && account.ExpiresAt.After(time.Now()) {
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
token, err := s.calendarConfig(ctx).Refresh(ctx, account.RefreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account.AccessToken = token.AccessToken
|
||||
account.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
|
||||
// refreshCalendarList fetches the calendar list with a token it obtains itself.
|
||||
// Used by the paths that have an account but no client yet — connecting, and the
|
||||
// status endpoint correcting an empty list.
|
||||
func (s *Server) refreshCalendarList(ctx context.Context, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.calendarList(ctx, s.calendarClient(access), account)
|
||||
}
|
||||
|
||||
// calendarList writes what the provider says and returns what is stored, which is
|
||||
// not the same list: the provider says what exists, and only verdande knows which
|
||||
// of them somebody chose to look at.
|
||||
func (s *Server) calendarList(ctx context.Context, client *gcal.Client, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
remote, err := client.Calendars(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]store.Calendar, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
list = append(list, store.Calendar{
|
||||
RemoteID: c.ID, Name: c.Name, Colour: c.Colour,
|
||||
TimeZone: c.TimeZone, Primary: c.Primary, Writable: c.Owner,
|
||||
})
|
||||
// The primary calendar is the account, and its id is the address. It is the
|
||||
// only place the connected account names itself without a second scope —
|
||||
// asking for a profile scope to learn an address already in the answer would
|
||||
// be one more thing on the consent screen for nothing.
|
||||
if c.Primary && account.Account == "" {
|
||||
account.Account = c.ID
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.db.ReplaceCalendars(ctx, account.ID, list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.db.Calendars(ctx, account.ID)
|
||||
}
|
||||
|
||||
// forgetIfRevoked disconnects when Google says the grant is gone, and passes every
|
||||
// other failure through untouched.
|
||||
//
|
||||
// The distinction is the point. A revoked account polled every ten minutes for ever
|
||||
// is a log line a week long and a connection the person believes they removed; a
|
||||
// Google that is merely down is one that will answer next time.
|
||||
func (s *Server) forgetIfRevoked(ctx context.Context, user *store.User, account *store.CalendarAccount, err error) error {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
s.log.Info("calendar access was revoked; disconnecting", "user", user.ID)
|
||||
_ = s.db.DeleteCalendarAccount(ctx, user.ID, account.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// calendarWindow is the span verdande keeps a copy of, measured from today.
|
||||
//
|
||||
// Built from the calendar date rather than by truncating the instant: Truncate
|
||||
// rounds against the Unix epoch, which is midnight UTC, and in Copenhagen that is
|
||||
// two in the morning — so for two hours of every summer day the window would start
|
||||
// and end a day out.
|
||||
func calendarWindow(now time.Time) (string, string) {
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return day.AddDate(0, 0, -calendarWindowBack).Format(calendarDateLayout),
|
||||
day.AddDate(0, 0, calendarWindowAhead).Format(calendarDateLayout)
|
||||
}
|
||||
|
||||
func validDay(s string) bool {
|
||||
_, err := time.Parse(calendarDateLayout, s)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// fakeCalendarGoogle stands in for the parts of Google this feature touches: the
|
||||
// token endpoint, the calendar list and one calendar's events.
|
||||
//
|
||||
// There is no other way to exercise any of it. The real thing needs a registered
|
||||
// OAuth client, an account and somebody's consent, and none of the three can be had
|
||||
// inside a test — so the shapes Google returns are written down and the behaviour
|
||||
// is checked against them, the way internal/imap is checked against go-imap's own
|
||||
// in-process server.
|
||||
type fakeCalendarGoogle struct {
|
||||
*httptest.Server
|
||||
// refreshes counts token refreshes, so a test can prove one is not made for a
|
||||
// token that has not expired.
|
||||
refreshes int
|
||||
// refuse makes every API call answer 401, which is what a revoked grant does.
|
||||
refuse bool
|
||||
events map[string][]map[string]any
|
||||
}
|
||||
|
||||
func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle {
|
||||
t.Helper()
|
||||
g := &fakeCalendarGoogle{events: map[string][]map[string]any{}}
|
||||
g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
g.refreshes++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "ya29.fresh", "refresh_token": "1//0-refresh",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
case g.refuse:
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{
|
||||
{"id": "[email protected]", "summary": "[email protected]", "primary": true,
|
||||
"accessRole": "owner", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen"},
|
||||
{"id": "fam", "summary": "Familien", "accessRole": "reader",
|
||||
"backgroundColor": "#0b8043"},
|
||||
}})
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
items := g.events[id]
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(g.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
// connectedCalendar returns a server with a calendar account already connected —
|
||||
// the state the OAuth callback leaves behind, without the consent screen nothing
|
||||
// can automate.
|
||||
func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) {
|
||||
t.Helper()
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = g.URL + "/token"
|
||||
cfg.CalendarAPIURL = g.URL
|
||||
if tune != nil {
|
||||
tune(cfg)
|
||||
}
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh",
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts, user
|
||||
}
|
||||
|
||||
// The scope on the consent screen is what somebody agrees to. It has to be the
|
||||
// read-only one, and the redirect has to be the calendar's own — sharing Gmail's
|
||||
// would put "which token store do I write" behind a lookup.
|
||||
func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
url, _ := body["url"].(string)
|
||||
for _, want := range []string{
|
||||
"scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
|
||||
"%2Foauth%2Fcalendar%2Fcallback",
|
||||
"access_type=offline",
|
||||
"prompt=consent",
|
||||
} {
|
||||
if !strings.Contains(url, want) {
|
||||
t.Errorf("the authorisation URL is missing %q:\n%s", want, url)
|
||||
}
|
||||
}
|
||||
if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") {
|
||||
t.Errorf("a writing scope was asked for:\n%s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a registered client there is nothing to connect through, and the answer
|
||||
// has to say that rather than "that already exists".
|
||||
func TestAuthorizeWithoutAClientSaysSo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeGmailNotConfigured {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole path, minus the consent screen: sync fetches the list, refreshes the
|
||||
// events of what was chosen, and the grid endpoint answers with them.
|
||||
func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}},
|
||||
}
|
||||
g.events["[email protected]"] = []map[string]any{
|
||||
{"id": "e2", "summary": "Skal ikke med",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
// The list arrives on the first look at the settings page, which is what
|
||||
// somebody would be doing at this point.
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["connected"] != true {
|
||||
t.Fatalf("not reported as connected: %v", body)
|
||||
}
|
||||
if body["read_only"] != true {
|
||||
t.Error("the connection did not say it is read-only; an interface that looks " +
|
||||
"editable is one somebody will try to edit")
|
||||
}
|
||||
// The primary calendar's id is the address, which is how the account names
|
||||
// itself without asking for a profile scope as well.
|
||||
if body["account"] != "[email protected]" {
|
||||
t.Errorf("account = %v", body["account"])
|
||||
}
|
||||
|
||||
list, _ := body["calendars"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"])
|
||||
}
|
||||
var famID string
|
||||
for _, raw := range list {
|
||||
c := raw.(map[string]any)
|
||||
if c["shown"] == true {
|
||||
t.Errorf("%v was shown without anybody choosing it", c["name"])
|
||||
}
|
||||
if c["remote_id"] == "fam" {
|
||||
famID = c["id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is chosen, so a sync writes nothing: an account with a dozen shared
|
||||
// calendars must not fill somebody's grid the moment it is connected.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["events"] != float64(0) {
|
||||
t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars",
|
||||
map[string]any{"shown": []string{famID}})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("choosing a calendar: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["events"] != float64(1) {
|
||||
t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET",
|
||||
"/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("events: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
events, _ := body["events"].([]any)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1: %v", len(events), body["events"])
|
||||
}
|
||||
event := events[0].(map[string]any)
|
||||
if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" {
|
||||
t.Errorf("the event came back as %v", event)
|
||||
}
|
||||
// The calendar's colour rides along, or the chip has to ask for it per row.
|
||||
if event["colour"] != "#0b8043" {
|
||||
t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"])
|
||||
}
|
||||
// The window verdande holds is stated, so a grid paged past it can say so
|
||||
// rather than showing an empty month it cannot explain.
|
||||
if body["from"] == "" || body["to"] == "" {
|
||||
t.Error("the answer does not say which window it covers")
|
||||
}
|
||||
|
||||
// And it is one person's calendar. Somebody else on the same instance sees
|
||||
// nothing of it.
|
||||
other := ts.newUser(t, "[email protected]", "Andreas")
|
||||
_, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("somebody else's calendar was visible: %v", body["events"])
|
||||
}
|
||||
_ = user
|
||||
}
|
||||
|
||||
// A token that has not expired must not be traded in for another. Refreshing on
|
||||
// every run burns the quota and makes the failure mode of a bad refresh token
|
||||
// arrive on every sweep rather than once an hour.
|
||||
func TestAFreshTokenIsNotRefreshed(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account.AccessToken = "ya29.still-good"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 0 {
|
||||
t.Errorf("the token was refreshed %d times while it was still good", g.refreshes)
|
||||
}
|
||||
|
||||
// And an expired one is.
|
||||
account.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 1 {
|
||||
t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes)
|
||||
}
|
||||
}
|
||||
|
||||
// Access revoked at Google's end is a connection that has ended, not a failure to
|
||||
// retry. Polling it every fifteen minutes for ever is a week of log lines and a
|
||||
// connection the person believes they removed.
|
||||
func TestRevokedAccessDisconnects(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
account.AccessToken = "ya29.revoked"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.refuse = true
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Fatalf("status %d, want %d — Google saying no is not this server breaking",
|
||||
resp.StatusCode, StatusUpstreamRefused)
|
||||
}
|
||||
if body["code"] != CodeCalendarFailed {
|
||||
t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+
|
||||
"which throws the diagnosis away", body["code"], CodeCalendarFailed)
|
||||
}
|
||||
|
||||
gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gone != nil {
|
||||
t.Error("the connection is still there after Google refused the token")
|
||||
}
|
||||
|
||||
// And the failure 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")
|
||||
}
|
||||
if row := rows[0].(map[string]any); row["what"] != "sync calendar" {
|
||||
t.Errorf("what = %v, want \"sync calendar\"", row["what"])
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the copy with it. None of it is the person's own work, and
|
||||
// a calendar left behind after the account is gone is a frozen lie about a day.
|
||||
func TestDisconnectingRemovesTheEvents(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Møde",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
calendars, _ := ts.db.Calendars(t.Context(), account.ID)
|
||||
var famID string
|
||||
for _, c := range calendars {
|
||||
if c.RemoteID == "fam" {
|
||||
famID = c.ID
|
||||
}
|
||||
}
|
||||
ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}})
|
||||
ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disconnect: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("events survived the disconnect: %v", body["events"])
|
||||
}
|
||||
_, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if body["connected"] != false {
|
||||
t.Error("still reported as connected")
|
||||
}
|
||||
}
|
||||
|
||||
// A range that is not a date has to be refused, not guessed at. An empty `from`
|
||||
// compared as a string matches everything before it, which is a grid quietly
|
||||
// answering a question nobody asked.
|
||||
func TestEventsRefuseARangeThatIsNotADate(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
for _, query := range []string{
|
||||
"", "?from=2026-08-01", "?from=&to=2026-08-31",
|
||||
"?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40",
|
||||
} {
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A person with no calendar connected still opens the view. It has to answer, and
|
||||
// answer emptily, rather than fail — the grid is also verdande's own tasks, and
|
||||
// they must not disappear because Google is not set up.
|
||||
func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if events, ok := body["events"].([]any); !ok || len(events) != 0 {
|
||||
t.Errorf("events = %v, want an empty list rather than null", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["connected"] != false {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["has_client"] != false {
|
||||
t.Error("an instance with no OAuth registration claimed to have one; the page " +
|
||||
"would offer a button that answers 409")
|
||||
}
|
||||
// Spelled out even when nothing is connected: it is what has to be registered
|
||||
// with Google, and it is the single most likely thing to be wrong.
|
||||
if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") {
|
||||
t.Errorf("redirect_uri = %q", uri)
|
||||
}
|
||||
}
|
||||
|
||||
// A slow Google must not hold the request open until something in front of this
|
||||
// server gives up on it and answers with its own HTML.
|
||||
func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) {
|
||||
blocked := make(chan struct{})
|
||||
defer close(blocked)
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer slow.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = slow.URL + "/token"
|
||||
cfg.CalendarAPIURL = slow.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
// would put half a minute into the suite to learn the same thing.
|
||||
cfg.CalendarSyncBudget = time.Second
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "r",
|
||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own client, because the shared one gives up after ten seconds — which is
|
||||
// shorter than the budget being tested and would prove nothing either way.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/calendar/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s); the sync has no budget of its "+
|
||||
"own, and whatever sits in front of this server answers for it",
|
||||
err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if took := time.Since(started); took > 10*time.Second {
|
||||
t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
comments, err := s.db.ListComments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list comments", err)
|
||||
s.internal(w, r, "list comments", err)
|
||||
return
|
||||
}
|
||||
out := make([]commentJSON, 0, len(comments))
|
||||
@@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list attachments", err)
|
||||
s.internal(w, r, "list attachments", err)
|
||||
return
|
||||
}
|
||||
direct := make([]attachmentJSON, 0, len(files))
|
||||
@@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
|
||||
if err != nil {
|
||||
s.internal(w, "create comment", err)
|
||||
s.internal(w, r, "create comment", err)
|
||||
return
|
||||
}
|
||||
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
|
||||
@@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
|
||||
if err != nil {
|
||||
s.storeError(w, "update comment", err)
|
||||
s.storeError(w, r, "update comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
taskID, err := s.db.CommentTask(r.Context(), commentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "comment task", err)
|
||||
s.storeError(w, r, "comment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
@@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
|
||||
s.storeError(w, "delete comment", err)
|
||||
s.storeError(w, r, "delete comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -219,28 +219,43 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, "store upload", err)
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
TaskID: taskID,
|
||||
CommentID: r.FormValue("comment_id"),
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
|
||||
// An attachment hangs on a task or on a comment, never both — the schema says
|
||||
// so, and the form may have supplied a comment id.
|
||||
if a.CommentID != "" {
|
||||
a.TaskID = ""
|
||||
//
|
||||
// The comment is resolved and checked against the task in the URL. It was
|
||||
// taken on trust, which meant the permission check above guarded the *task
|
||||
// somebody named* while the file landed on *whatever comment they asked for*.
|
||||
// A viewer on a shared project can list its comments, so she could read an id
|
||||
// there and then post to a task in her own project with that id attached: the
|
||||
// file appears under somebody else's comment, in a project she may only read,
|
||||
// and is served to everyone in it from this origin under a name she chose.
|
||||
// Somebody removed from a project could do the same with ids they kept.
|
||||
if id := r.FormValue("comment_id"); id != "" {
|
||||
owner, err := s.db.CommentTask(r.Context(), id)
|
||||
if err != nil || owner != taskID {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
a.CommentID, a.TaskID = id, ""
|
||||
}
|
||||
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, "record attachment", err)
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,23 +315,79 @@ func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
|
||||
return rel, size, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
// reachAttachment answers whether this person may see an attachment at all, and
|
||||
// whether they may change it.
|
||||
//
|
||||
// An attachment hangs on exactly one of three things, and each has its own idea of
|
||||
// who may reach it. A task's is governed by its project's roles, directly or
|
||||
// through the comment it hangs on. A group's is governed by nothing but ownership:
|
||||
// a group belongs to one person, is never shared, and a group that is not yours is
|
||||
// not found — the same rule the rest of the group routes use.
|
||||
//
|
||||
// Both callers below used to resolve straight to a task, so a group's attachment
|
||||
// answered 404 for its own owner.
|
||||
func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit bool, err error) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if a.GroupID != "" {
|
||||
if _, err := s.db.GetProjectGroup(r.Context(), a.GroupID, user.ID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Nobody else can reach it, so whoever can, can change it.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// A file inside a note follows the note, which follows its project — the same
|
||||
// chain everything else here follows. Without this a picture imported with a
|
||||
// note answered 404 while sitting in the middle of the text that named it.
|
||||
if a.NoteID != "" {
|
||||
n, err := s.db.Note(r.Context(), a.NoteID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n == nil {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
if n.ProjectID == "" {
|
||||
if n.CreatedBy != user.ID {
|
||||
return false, store.ErrNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
role, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, store.RoleViewer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role.CanEdit(), nil
|
||||
}
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
return
|
||||
return false, err
|
||||
}
|
||||
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role.CanEdit(), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
|
||||
if _, err := s.reachAttachment(r, attachmentID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get attachment", err)
|
||||
s.storeError(w, r, "get attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -338,12 +409,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
||||
// HTML file displayed inline would run its own script on this origin, with the
|
||||
// session cookie attached — attachments are the one place a user supplies
|
||||
// content that another user opens.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
||||
// Almost everything is served as a download rather than rendered. An uploaded
|
||||
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||
// with the session cookie attached — attachments are the one place a user
|
||||
// supplies content that another user opens.
|
||||
//
|
||||
// The exception is a short list of raster images, and it is not a softening of
|
||||
// that rule but what makes a note work: a note carries its pictures in the text
|
||||
// as ``, and served as octet-stream every one of them is a broken image.
|
||||
// The whole Apple Notes import exists to bring those pictures along.
|
||||
//
|
||||
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||
// type that can run script.
|
||||
disposition := "attachment"
|
||||
contentType := "application/octet-stream"
|
||||
if inlineImage[a.MimeType] {
|
||||
disposition = "inline"
|
||||
contentType = a.MimeType
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||
@@ -352,24 +441,36 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// inlineImage is what may be shown rather than downloaded.
|
||||
//
|
||||
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||
// can run script, and a list that grows by accident is how that happens.
|
||||
var inlineImage = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/avif": true,
|
||||
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
canEdit, err := s.reachAttachment(r, attachmentID)
|
||||
if err != nil || !canEdit {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete attachment", err)
|
||||
s.storeError(w, r, "delete attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||
//
|
||||
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
|
||||
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
|
||||
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
|
||||
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
|
||||
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
policy := resp.Header.Get("Content-Security-Policy")
|
||||
if policy == "" {
|
||||
t.Fatal("der er ingen Content-Security-Policy på svaret")
|
||||
}
|
||||
|
||||
// Målt på script-src alene, og ikke på hele politikken.
|
||||
//
|
||||
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
|
||||
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
|
||||
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
|
||||
// bliver til et kørende script, og det er derfor den, prøven ser på.
|
||||
scriptSrc := ""
|
||||
for _, part := range strings.Split(policy, ";") {
|
||||
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
|
||||
scriptSrc = part
|
||||
}
|
||||
}
|
||||
if scriptSrc == "" {
|
||||
t.Fatal("der er ingen script-src i politikken")
|
||||
}
|
||||
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
|
||||
if strings.Contains(scriptSrc, forbidden) {
|
||||
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
|
||||
}
|
||||
}
|
||||
|
||||
// Og de fire, der skal være der.
|
||||
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
|
||||
if !strings.Contains(policy, want) {
|
||||
t.Errorf("politikken mangler %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Et bilag må ikke kunne blive læst som et dokument.
|
||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||
if inlineImage[mime] {
|
||||
t.Errorf("%s står på listen over det, der vises inline", mime)
|
||||
}
|
||||
}
|
||||
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
|
||||
for _, mime := range []string{"image/png", "image/jpeg"} {
|
||||
if !inlineImage[mime] {
|
||||
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -51,6 +51,23 @@ const (
|
||||
// is not "that already exists" either, and it is the one refusal on the user
|
||||
// page that a person needs explained rather than merely reported.
|
||||
CodeLastAdmin = "last_admin"
|
||||
|
||||
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
|
||||
// "insufficient authentication scopes" — and they are useless behind a generic
|
||||
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
|
||||
// read "something went wrong", and had nowhere to go. The interface wraps the
|
||||
// message in one that says where it came from.
|
||||
CodeGmailFailed = "gmail_failed"
|
||||
|
||||
// And the same for a mailbox read over IMAP. A host's refusal is the whole
|
||||
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
||||
// is not there — and it says it in its own words.
|
||||
CodeMailboxFailed = "mailbox_failed"
|
||||
|
||||
// And for a calendar read from Google. Its own code rather than the Gmail one,
|
||||
// because the sentence the interface wraps it in names where it came from — and
|
||||
// "Gmail said no" about a calendar sends somebody to the wrong panel.
|
||||
CodeCalendarFailed = "calendar_failed"
|
||||
)
|
||||
|
||||
// errLastAdmin is returned to the caller that already wrote the response, so it
|
||||
|
||||
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed", err)
|
||||
s.internal(w, r, "ics feed", err)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed projects", err)
|
||||
s.internal(w, r, "ics feed projects", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
@@ -89,7 +89,7 @@ func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ics token", err)
|
||||
s.internal(w, r, "ics token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
@@ -103,11 +103,11 @@ func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, "generate feed token", err)
|
||||
s.internal(w, r, "generate feed token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
||||
s.internal(w, "set feed token", err)
|
||||
s.internal(w, r, "set feed token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
@@ -151,7 +151,7 @@ func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list reminders", err)
|
||||
s.internal(w, r, "list reminders", err)
|
||||
return
|
||||
}
|
||||
out := make([]reminderJSON, 0, len(reminders))
|
||||
@@ -197,7 +197,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
||||
if err != nil {
|
||||
s.internal(w, "create reminder", err)
|
||||
s.internal(w, r, "create reminder", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete reminder", err)
|
||||
s.storeError(w, r, "delete reminder", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
|
||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list templates", err)
|
||||
s.internal(w, r, "list templates", err)
|
||||
return
|
||||
}
|
||||
out := make([]templateJSON, 0, len(templates))
|
||||
@@ -273,7 +273,7 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
||||
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
||||
if err != nil {
|
||||
s.storeError(w, "save template", err)
|
||||
s.storeError(w, r, "save template", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
||||
@@ -295,7 +295,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get template", err)
|
||||
s.storeError(w, r, "get template", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
||||
if err != nil {
|
||||
s.internal(w, "create from template", err)
|
||||
s.internal(w, r, "create from template", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, project.ID, "", "project.created", map[string]any{
|
||||
@@ -323,7 +323,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete template", err)
|
||||
s.storeError(w, r, "delete template", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
|
||||
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
||||
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list filters", err)
|
||||
s.internal(w, r, "list filters", err)
|
||||
return
|
||||
}
|
||||
out := make([]filterJSON, 0, len(filters))
|
||||
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
f.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
||||
s.internal(w, "create filter", err)
|
||||
s.internal(w, r, "create filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
|
||||
@@ -108,7 +108,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
s.storeError(w, "update filter", err)
|
||||
s.storeError(w, r, "update filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -117,7 +117,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete filter", err)
|
||||
s.storeError(w, r, "delete filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -129,7 +129,7 @@ func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get filter", err)
|
||||
s.storeError(w, r, "get filter", err)
|
||||
return
|
||||
}
|
||||
s.runQuery(w, r, f.Query)
|
||||
@@ -167,7 +167,7 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string)
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "run filter", err)
|
||||
s.internal(w, r, "run filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||
@@ -210,7 +210,7 @@ type labelJSON struct {
|
||||
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
|
||||
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list labels", err)
|
||||
s.internal(w, r, "list labels", err)
|
||||
return
|
||||
}
|
||||
out := make([]labelJSON, 0, len(labels))
|
||||
@@ -242,7 +242,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create label", err)
|
||||
s.internal(w, r, "create label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
@@ -261,7 +261,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "update label", err)
|
||||
s.storeError(w, r, "update label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
@@ -271,7 +271,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete label", err)
|
||||
s.storeError(w, r, "delete label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,12 +11,56 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) gmailConfig() gmail.Config {
|
||||
return gmail.Config{
|
||||
// defaultGmailSyncBudget bounds one "fetch now" when nothing else says otherwise.
|
||||
// Well under the hundred seconds a proxy is willing to wait, so this server is the
|
||||
// one that answers a slow mailbox.
|
||||
const defaultGmailSyncBudget = 25 * time.Second
|
||||
|
||||
// gmailConfig is the OAuth client this instance authorises with.
|
||||
//
|
||||
// The environment wins when it is set. A value in the environment is a deliberate
|
||||
// deployment decision — it is in the Rune's manifest, under version control,
|
||||
// applied on every recreate — and a form that could quietly override it would make
|
||||
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
|
||||
// to redeploy a container to paste a client id.
|
||||
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||
return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL)
|
||||
}
|
||||
|
||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
cfg := gmail.Config{
|
||||
ClientID: s.cfg.GmailClientID,
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
Scope: gmail.Scope,
|
||||
TokenURL: s.cfg.GoogleTokenURL,
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||
return cfg
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, "gmail")
|
||||
if err != nil {
|
||||
// Not fatal: an unconfigured Gmail and a database that could not answer
|
||||
// look the same from here, and the caller's next step is the same either
|
||||
// way — say it is not set up.
|
||||
return cfg
|
||||
}
|
||||
if id, _ := stored["client_id"].(string); cfg.ClientID == "" {
|
||||
cfg.ClientID = id
|
||||
}
|
||||
if secret, _ := stored["client_secret"].(string); cfg.ClientSecret == "" {
|
||||
cfg.ClientSecret = secret
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// gmailFromEnv says whether the deployment set the client, which the settings page
|
||||
// needs so it can explain why the fields are not editable rather than accepting
|
||||
// changes that will never take effect.
|
||||
func (s *Server) gmailFromEnv() bool {
|
||||
return s.cfg.GmailClientID != "" && s.cfg.GmailClientSecret != ""
|
||||
}
|
||||
|
||||
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
|
||||
@@ -27,7 +72,7 @@ func (s *Server) gmailConfig() gmail.Config {
|
||||
// defaults.
|
||||
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
cfg := s.gmailConfig()
|
||||
cfg := s.gmailConfig(r.Context())
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
@@ -37,13 +82,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
pkce, err := gmail.NewPKCE()
|
||||
if err != nil {
|
||||
s.internal(w, "gmail pkce", err)
|
||||
s.internal(w, r, "gmail pkce", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
settings["pkce_verifier"] = pkce.Verifier
|
||||
@@ -53,7 +98,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||
@@ -83,7 +128,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
verifier, _ := settings["pkce_verifier"].(string)
|
||||
@@ -102,7 +147,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.gmailConfig().Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
|
||||
token, err := s.gmailConfig(r.Context()).Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
|
||||
if err != nil {
|
||||
s.log.Error("gmail exchange", "err", err, "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
|
||||
@@ -118,7 +163,10 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
||||
// Best effort: a mailbox that connects but cannot name itself is still
|
||||
// connected, and the status endpoint asks again when it finds no address —
|
||||
// so this failing is a delay rather than a dead end.
|
||||
email, err := s.gmailClient(token.AccessToken).Profile(r.Context())
|
||||
if err != nil {
|
||||
s.log.Warn("gmail profile", "err", err)
|
||||
}
|
||||
@@ -129,16 +177,28 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
delete(settings, "pkce_state")
|
||||
delete(settings, "pkce_expires")
|
||||
|
||||
settings["refresh_token"] = token.RefreshToken
|
||||
settings["access_token"] = token.AccessToken
|
||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||
settings["email"] = email
|
||||
if _, ok := settings["trigger"]; !ok {
|
||||
settings["trigger"] = "starred"
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, r, "clear gmail handshake", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, "save gmail tokens", err)
|
||||
// The mailbox itself, kept beside the ones read over IMAP. Connecting again
|
||||
// writes the same row: Gmail is one per person, because the OAuth flow is.
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "read gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
if box == nil {
|
||||
box = &store.Mailbox{UserID: user.ID, Kind: "gmail", Trigger: "starred"}
|
||||
}
|
||||
box.Name = email
|
||||
box.Username = email
|
||||
box.RefreshToken = token.RefreshToken
|
||||
box.AccessToken = token.AccessToken
|
||||
box.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
||||
s.internal(w, r, "save gmail tokens", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
||||
@@ -150,9 +210,36 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
created, err := s.SyncGmail(r.Context(), user)
|
||||
// A budget of its own, well under what sits in front of this server.
|
||||
//
|
||||
// The sync fetches up to twenty-five messages one at a time, each with its own
|
||||
// thirty-second timeout — so a slow mailbox can hold the request open for
|
||||
// minutes. Cloudflare gives up at a hundred seconds and answers with its own
|
||||
// 502 page, which is HTML: the browser gets no JSON, no error code and no
|
||||
// message, and shows "something went wrong". Nothing reaches the error log
|
||||
// either, because this handler never returned.
|
||||
//
|
||||
// Whatever was created by the deadline is kept: every task is committed as it
|
||||
// is made, so a short budget is a shorter run rather than a lost one. The
|
||||
// background job keeps the full one; it has nobody waiting.
|
||||
// An unset budget is not a budget of nothing: a zero here would expire the
|
||||
// context before the first call and make every sync a silent no-op.
|
||||
budget := s.cfg.GmailSyncBudget
|
||||
if budget <= 0 {
|
||||
budget = defaultGmailSyncBudget
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||
defer cancel()
|
||||
|
||||
created, err := s.SyncGmail(ctx, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Not a failure: the mailbox is slow or busy, some tasks were made, and
|
||||
// the rest arrive on the next run ten minutes from now.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
||||
@@ -162,49 +249,64 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns how many were made. Also used by the background job, which is why it
|
||||
// takes a context and a user rather than a request.
|
||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||
//
|
||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
||||
// asking for less is what makes the consent screen something a person can agree
|
||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
||||
// record has to live on this side, and a record on this side has to be bounded.
|
||||
//
|
||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||
// which was not a bound but a way of forgetting.
|
||||
const maxGmailSeen = 2000
|
||||
|
||||
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
settings, err := s.db.UserSettings(ctx, user.ID, "gmail")
|
||||
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
str := func(key string) string {
|
||||
v, _ := settings[key].(string)
|
||||
return v
|
||||
if box != nil {
|
||||
// The same guard as the mailboxes read over IMAP, for the same reason: a
|
||||
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||
// one marker.
|
||||
unlock := s.lockSync(box.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||
box = fresh
|
||||
}
|
||||
}
|
||||
|
||||
refresh := str("refresh_token")
|
||||
if refresh == "" {
|
||||
if box == nil || box.RefreshToken == "" {
|
||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
||||
}
|
||||
query := gmail.Query(str("trigger"), str("label"))
|
||||
query := gmail.Query(box.Trigger, box.Label)
|
||||
if query == "" {
|
||||
return 0, nil // connected but no trigger chosen
|
||||
}
|
||||
refresh := box.RefreshToken
|
||||
|
||||
// The access token is refreshed only when it has actually expired.
|
||||
access := str("access_token")
|
||||
expires, _ := settings["expires_at"].(float64)
|
||||
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||
token, err := s.gmailConfig().Refresh(ctx, refresh)
|
||||
access := box.AccessToken
|
||||
if access == "" || box.ExpiresAt.Before(time.Now()) {
|
||||
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
access = token.AccessToken
|
||||
settings["access_token"] = access
|
||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||
box.AccessToken = access
|
||||
box.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
client := gmail.NewClient(access)
|
||||
client := s.gmailClient(access)
|
||||
ids, err := client.List(ctx, query, 25)
|
||||
if err != nil {
|
||||
if err == gmail.ErrUnauthorized {
|
||||
// The person revoked access, or changed their password. Forget the
|
||||
// tokens rather than retrying every ten minutes forever.
|
||||
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
|
||||
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{})
|
||||
_ = s.db.DeleteMailbox(ctx, user.ID, box.ID)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
@@ -213,19 +315,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// rather than as a table: it is bounded by the query's own 30-day window, and
|
||||
// a table would be one more thing to migrate for a feature this small.
|
||||
seen := map[string]bool{}
|
||||
if raw, ok := settings["seen"].([]any); ok {
|
||||
for _, v := range raw {
|
||||
if id, ok := v.(string); ok {
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range box.Seen {
|
||||
seen[id] = true
|
||||
}
|
||||
|
||||
inbox, err := s.db.InboxID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
projectID := str("project_id")
|
||||
projectID := box.ProjectID
|
||||
if projectID == "" {
|
||||
projectID = inbox
|
||||
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||
@@ -237,6 +335,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
// A spent budget ends the run rather than turning into twenty-five failed
|
||||
// fetches logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
}
|
||||
msg, err := client.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||
@@ -268,17 +371,38 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// Only the ids Gmail still returns are kept, so the list cannot grow
|
||||
// without bound as messages fall out of the 30-day window.
|
||||
keep := make([]any, 0, len(ids))
|
||||
// The ids this run saw, on top of the ones already known.
|
||||
//
|
||||
// It used to keep only the ids Gmail returned *this run*, to stop the list
|
||||
// growing without bound. That threw away every id that was not on the page
|
||||
// it happened to fetch — and the query is capped, so on an account with more
|
||||
// starred mail than one page, each run forgot the messages the last one had
|
||||
// remembered. They came back unseen, and became tasks again, and again. That
|
||||
// is the duplicate.
|
||||
//
|
||||
// Bounded by a count instead. Newest first, and a ceiling far above any
|
||||
// plausible page, so an id leaves the list because it is old rather than
|
||||
// because it was not in the last answer.
|
||||
keep := make([]string, 0, len(ids)+len(box.Seen))
|
||||
added := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
if seen[id] && !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
settings["seen"] = keep
|
||||
settings["last_sync"] = time.Now().Unix()
|
||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||
for _, id := range box.Seen {
|
||||
if !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
if len(keep) > maxGmailSeen {
|
||||
keep = keep[:maxGmailSeen]
|
||||
}
|
||||
box.Seen = keep
|
||||
box.LastSyncAt = time.Now()
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
return created, err
|
||||
}
|
||||
}
|
||||
@@ -300,3 +424,127 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// --- the OAuth client itself ------------------------------------------------------
|
||||
|
||||
// The registration Google requires, enterable rather than deployed.
|
||||
//
|
||||
// The one click that opens Google's consent screen already exists; what it needs
|
||||
// behind it is a registered client, and that is not something any app can conjure:
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
|
||||
// a restricted scope, so an id shipped inside a public image would be both
|
||||
// extractable from it and unusable without Google's security review of every
|
||||
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
||||
// recreate the container to paste the result was not.
|
||||
//
|
||||
// Administrators only, and sessions only: this is the instance's identity to
|
||||
// Google, and a leaked API token must not be able to read or replace it.
|
||||
|
||||
type gmailClientJSON struct {
|
||||
ClientID string `json:"client_id"`
|
||||
// Secret is written and never read back, like the AI key: a settings page that
|
||||
// repopulates a password field is one that will eventually leak into a
|
||||
// screenshot.
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
// FromEnv says the deployment set this, in which case the stored pair is
|
||||
// ignored and the fields are not worth editing. Sent so the page can say why
|
||||
// rather than accepting changes that will never take effect.
|
||||
FromEnv bool `json:"from_env"`
|
||||
// RedirectURI is what has to be registered with Google, spelled out. It is
|
||||
// derived from VERDANDE_BASE_URL and must match exactly — it is the single
|
||||
// most likely thing to be wrong, and the error Google gives for it names
|
||||
// neither value.
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetGmailClient(w http.ResponseWriter, r *http.Request) {
|
||||
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "gmail client", err)
|
||||
return
|
||||
}
|
||||
out := gmailClientJSON{
|
||||
FromEnv: s.gmailFromEnv(),
|
||||
RedirectURI: s.cfg.GmailRedirectURL(),
|
||||
}
|
||||
if out.FromEnv {
|
||||
out.ClientID = s.cfg.GmailClientID
|
||||
out.HasSecret = true
|
||||
} else {
|
||||
out.ClientID, _ = stored["client_id"].(string)
|
||||
secret, _ := stored["client_secret"].(string)
|
||||
out.HasSecret = secret != ""
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
|
||||
var req gmailClientJSON
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if s.gmailFromEnv() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"the Gmail client is set in the environment and cannot be changed here")
|
||||
return
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "gmail client", err)
|
||||
return
|
||||
}
|
||||
// An empty secret means "leave it alone", not "delete it" — otherwise
|
||||
// correcting a typo in the client id would silently clear the secret.
|
||||
secret, _ := stored["client_secret"].(string)
|
||||
if req.ClientSecret != "" {
|
||||
secret = strings.TrimSpace(req.ClientSecret)
|
||||
}
|
||||
id := strings.TrimSpace(req.ClientID)
|
||||
|
||||
if fields := map[string]string{}; id == "" && secret != "" {
|
||||
fields["client_id"] = "required"
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.SetInstanceSettings(r.Context(), "gmail", map[string]any{
|
||||
"client_id": id, "client_secret": secret,
|
||||
}); err != nil {
|
||||
s.internal(w, r, "save gmail client", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// gmailProfile fetches the mailbox address and stores it, refreshing the access
|
||||
// token first if it has expired.
|
||||
//
|
||||
// Shared by the connect callback and the status endpoint. The callback is the
|
||||
// natural place to learn the address, and it is also the one moment that cannot be
|
||||
// retried — so the status endpoint asks again when it finds nothing, and the answer
|
||||
// corrects itself on the next look.
|
||||
func (s *Server) gmailProfile(ctx context.Context, box *store.Mailbox) (string, error) {
|
||||
access := box.AccessToken
|
||||
if access == "" || box.ExpiresAt.Before(time.Now()) {
|
||||
token, err := s.gmailConfig(ctx).Refresh(ctx, box.RefreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
access = token.AccessToken
|
||||
box.AccessToken = access
|
||||
box.ExpiresAt = token.ExpiresAt
|
||||
}
|
||||
|
||||
email, err := s.gmailClient(access).Profile(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
box.Name = email
|
||||
box.Username = email
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// A Gmail failure has to survive being reported.
|
||||
//
|
||||
// It used to be hidden twice over. The handler answered 502 with the code
|
||||
// `internal_error`, which the interface maps to "something went wrong" — so
|
||||
// Google's own words, the only thing that says what to do, were replaced by a
|
||||
// sentence that says nothing. And `writeError` records nothing, so the error log
|
||||
// was empty: a person clicked Fetch now, read a generic failure, and there was
|
||||
// nowhere to look it up afterwards.
|
||||
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
// A Gmail that refuses, the way an expired grant does.
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Connected, with a refresh token that will be refused.
|
||||
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||
UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
||||
resp.StatusCode)
|
||||
}
|
||||
if body["code"] != "gmail_failed" {
|
||||
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
|
||||
"sentence, which throws the diagnosis away", body["code"])
|
||||
}
|
||||
if msg, _ := body["error"].(string); msg == "" {
|
||||
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
|
||||
}
|
||||
|
||||
// And it is in the error log, which is the point of having one: a sync that has
|
||||
// been failing for a week is invisible otherwise.
|
||||
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := logged["errors"].([]any)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["what"] != "sync gmail" {
|
||||
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
||||
}
|
||||
if row["status"] != float64(StatusUpstreamRefused) {
|
||||
t.Errorf("status = %v, want 502", row["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// A slow mailbox must not hold the request open until something in front of this
|
||||
// server gives up on it.
|
||||
//
|
||||
// This is what was actually happening in production: each Gmail call has its own
|
||||
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
|
||||
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
|
||||
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
|
||||
// showed "something went wrong". The handler had never returned.
|
||||
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
// A Gmail that never answers.
|
||||
blocked := make(chan struct{})
|
||||
defer close(blocked)
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
// would put half a minute into the suite to learn the same thing.
|
||||
cfg.GmailSyncBudget = time.Second
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A token that has not expired, so the run goes straight to the message list
|
||||
// rather than stopping at a refresh.
|
||||
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own client, because the shared one gives up after ten seconds — which is
|
||||
// shorter than the budget being tested and would prove nothing either way.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
|
||||
"its own, and whatever sits in front of this server answers for it",
|
||||
err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
took := time.Since(started)
|
||||
|
||||
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
|
||||
// bug, whatever status eventually arrives.
|
||||
// Comfortably over the one-second budget and far under any proxy's patience.
|
||||
if took > 10*time.Second {
|
||||
t.Errorf("the sync took %s; the budget is not being applied",
|
||||
took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
|
||||
project := todoist.FromRows(rows, name)
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import project", err)
|
||||
s.internal(w, r, "import project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import csv", err)
|
||||
s.internal(w, r, "import csv", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
@@ -255,12 +255,12 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
exported, err := s.buildExport(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "export project", err)
|
||||
s.internal(w, r, "export project", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "export projects", err)
|
||||
s.internal(w, r, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export tasks", err)
|
||||
s.internal(w, r, "export tasks", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
@@ -445,14 +445,14 @@ func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export ics", err)
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,7 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "mail token", err)
|
||||
s.internal(w, r, "mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
@@ -49,11 +50,11 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, "generate mail token", err)
|
||||
s.internal(w, r, "generate mail token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
||||
s.internal(w, "set mail token", err)
|
||||
s.internal(w, r, "set mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
@@ -134,7 +135,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, "create task from mail", err)
|
||||
s.internal(w, r, "create task from mail", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,7 +188,7 @@ type aiSettings struct {
|
||||
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
// The key is never sent back. A settings page that repopulates a password
|
||||
@@ -214,9 +215,27 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The address is checked where it is typed, as well as when it is dialled.
|
||||
//
|
||||
// safedial refuses a private destination at connect time and is the actual
|
||||
// wall; this is so the person entering it hears about it now rather than
|
||||
// discovering it later as a failed summary. A scheme other than http or https
|
||||
// is refused outright — file:// and gopher:// are not providers.
|
||||
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
if reason := safedial.CheckURL(url); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||
return
|
||||
}
|
||||
req.BaseURL = url
|
||||
}
|
||||
|
||||
existing, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
||||
@@ -231,7 +250,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
"model": req.Model, "api_key": key,
|
||||
}
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
|
||||
s.internal(w, "save ai settings", err)
|
||||
s.internal(w, r, "save ai settings", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -266,7 +285,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
@@ -278,7 +297,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -288,7 +307,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai split", "err", err)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,7 +318,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
Priority: task.Priority, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
|
||||
s.internal(w, "create subtask", err)
|
||||
s.internal(w, r, "create subtask", err)
|
||||
return
|
||||
}
|
||||
created = append(created, toTaskJSON(*child))
|
||||
@@ -315,7 +334,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cfg, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
@@ -326,7 +345,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
||||
if err != nil {
|
||||
s.internal(w, "list tasks", err)
|
||||
s.internal(w, r, "list tasks", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
@@ -352,7 +371,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai summary", "err", err)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
||||
@@ -370,20 +389,39 @@ type gmailSettings struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
||||
user := userFrom(r.Context())
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
str := func(key string) string {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
if box == nil {
|
||||
writeJSON(w, http.StatusOK, gmailSettings{})
|
||||
return
|
||||
}
|
||||
|
||||
// The mailbox address, fetched again if it is missing.
|
||||
//
|
||||
// It is stored at connect time, and the call that fetches it can fail — a slow
|
||||
// token, a scope Google had not applied yet. That failure used to be a log line
|
||||
// and an empty string, so the page said "an unknown account" and went on saying
|
||||
// it forever: the only way back was to disconnect and reconnect, which nobody
|
||||
// would guess. Asking again here costs one request on a page nobody opens
|
||||
// often, and it means the wrong answer lasts until the next look.
|
||||
email := box.Username
|
||||
if email == "" && box.RefreshToken != "" {
|
||||
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
|
||||
email = fetched
|
||||
} else {
|
||||
s.log.Warn("gmail profile", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, gmailSettings{
|
||||
Connected: str("refresh_token") != "",
|
||||
Email: str("email"),
|
||||
Trigger: str("trigger"),
|
||||
Label: str("label"),
|
||||
Connected: box.RefreshToken != "",
|
||||
Email: email,
|
||||
Trigger: box.Trigger,
|
||||
Label: box.Label,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,27 +432,42 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
values["trigger"] = req.Trigger
|
||||
values["label"] = req.Label
|
||||
if box == nil {
|
||||
// Choosing what makes a task before connecting is allowed, and the choice is
|
||||
// kept: the row is the mailbox with no credential in it yet, which reads as
|
||||
// not connected everywhere that asks and is skipped by the sweep.
|
||||
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
|
||||
}
|
||||
box.Trigger = req.Trigger
|
||||
box.Label = req.Label
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
||||
s.internal(w, "save gmail settings", err)
|
||||
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
||||
s.internal(w, r, "save gmail settings", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDisconnectGmail forgets the connection, tokens and all.
|
||||
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
|
||||
// stay: they are the person's work now, not the mailbox's.
|
||||
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
|
||||
s.internal(w, "disconnect gmail", err)
|
||||
user := userFrom(r.Context())
|
||||
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "gmail mailbox", err)
|
||||
return
|
||||
}
|
||||
if box != nil {
|
||||
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
|
||||
s.internal(w, r, "disconnect gmail", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -1153,3 +1153,166 @@ func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
|
||||
}
|
||||
}
|
||||
|
||||
// The Gmail OAuth client, enterable rather than deployed.
|
||||
//
|
||||
// The registration itself cannot be avoided — Google issues no Gmail access to an
|
||||
// unregistered client — but having to edit a Rune's manifest and recreate the
|
||||
// container to paste the result can be, and that is what this covers.
|
||||
func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, empty := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if empty["client_id"] != "" || empty["has_secret"] != false {
|
||||
t.Errorf("a fresh instance already has a client: %v", empty)
|
||||
}
|
||||
if empty["from_env"] != false {
|
||||
t.Errorf("from_env = %v with nothing in the environment", empty["from_env"])
|
||||
}
|
||||
// The single most likely thing to be wrong, so it is spelled out rather than
|
||||
// left for somebody to reconstruct.
|
||||
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
|
||||
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
|
||||
"client_id": "1234.apps.googleusercontent.com", "client_secret": "hemmelig",
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("save: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, saved := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if saved["client_id"] != "1234.apps.googleusercontent.com" {
|
||||
t.Errorf("client_id = %v", saved["client_id"])
|
||||
}
|
||||
if saved["has_secret"] != true {
|
||||
t.Error("has_secret is false after saving one")
|
||||
}
|
||||
// Never read back. A settings page that repopulates a password field is one
|
||||
// that will eventually leak into a screenshot.
|
||||
if _, present := saved["client_secret"]; present {
|
||||
t.Error("the secret came back in the response")
|
||||
}
|
||||
|
||||
// Saving only the id must not clear the secret — otherwise correcting a typo
|
||||
// silently breaks the connection.
|
||||
ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
|
||||
"client_id": "5678.apps.googleusercontent.com",
|
||||
})
|
||||
_, again := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if again["client_id"] != "5678.apps.googleusercontent.com" || again["has_secret"] != true {
|
||||
t.Errorf("saving the id alone lost the secret: %v", again)
|
||||
}
|
||||
|
||||
// And the authorise call now has something to build a URL with, which is the
|
||||
// whole point: one click, and Google's consent screen.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authorize with a stored client: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if url, _ := body["url"].(string); !strings.Contains(url, "5678.apps.googleusercontent.com") {
|
||||
t.Errorf("the consent URL does not carry the stored client: %v", body["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// The environment wins, and says so. A value in the Rune's manifest is applied on
|
||||
// every recreate, so a form that could quietly override it would make the manifest
|
||||
// a lie.
|
||||
func TestTheEnvironmentsGmailClientCannotBeOverwritten(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.GmailClientID = "fra-miljøet.apps.googleusercontent.com"
|
||||
c.GmailClientSecret = "også-fra-miljøet"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, shown := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if shown["from_env"] != true {
|
||||
t.Errorf("from_env = %v with the environment set", shown["from_env"])
|
||||
}
|
||||
if shown["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
|
||||
t.Errorf("client_id = %v", shown["client_id"])
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
|
||||
"client_id": "min-egen", "client_secret": "min-egen",
|
||||
})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("overwriting the environment: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if after["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
|
||||
t.Errorf("the environment's client was replaced: %v", after["client_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// Administrators only, and sessions only: this is the instance's identity to
|
||||
// Google, and a leaked token must not be able to read or replace it.
|
||||
func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, c := range []struct {
|
||||
method string
|
||||
body any
|
||||
}{{"GET", nil}, {"PUT", map[string]any{"client_id": "x"}}} {
|
||||
if resp, _ := other.do(t, c.method, "/api/v1/gmail/client", c.body); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", c.method, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/gmail/client", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotating the mail-to-task address, which reported "something went wrong" in
|
||||
// production with nothing in the error log — the combination that means the 500 did
|
||||
// not come through `s.internal()`.
|
||||
func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, before := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get: status %d, body %v", resp.StatusCode, before)
|
||||
}
|
||||
first, _ := before["address"].(string)
|
||||
if first == "" {
|
||||
t.Fatal("no address to begin with")
|
||||
}
|
||||
|
||||
resp, after := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("rotate: status %d, body %v", resp.StatusCode, after)
|
||||
}
|
||||
second, _ := after["address"].(string)
|
||||
if second == "" || second == first {
|
||||
t.Errorf("the address did not change: %q → %q", first, second)
|
||||
}
|
||||
|
||||
// And twice in a row, which is what somebody does when the first one appeared
|
||||
// to fail.
|
||||
resp, again := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second rotate: status %d, body %v", resp.StatusCode, again)
|
||||
}
|
||||
if third, _ := again["address"].(string); third == second {
|
||||
t.Error("the second rotation returned the same address")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kristianwind/verdande/internal/imap"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// defaultMailboxSyncBudget bounds one read of one mailbox when somebody is waiting.
|
||||
// The same reasoning as Gmail's: a mailbox that has stopped answering must not
|
||||
// hold a request open until whatever sits in front of this server answers for it.
|
||||
const defaultMailboxSyncBudget = 25 * time.Second
|
||||
|
||||
func (s *Server) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
list, err := s.db.Mailboxes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list mailboxes", err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []store.Mailbox{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"mailboxes": list})
|
||||
}
|
||||
|
||||
// handleAddMailbox connects one, after proving it can actually be read.
|
||||
//
|
||||
// Tested before it is saved, not after: a mailbox that was accepted and then
|
||||
// silently fails every ten minutes is worse than one that refused at the door,
|
||||
// where the person is still holding the password they just typed.
|
||||
func (s *Server) handleAddMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Folder string `json:"folder"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body.Host = strings.TrimSpace(body.Host)
|
||||
body.Username = strings.TrimSpace(body.Username)
|
||||
if body.Host == "" || body.Username == "" || body.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, CodeValidation,
|
||||
"a mailbox needs a host, a username and a password")
|
||||
return
|
||||
}
|
||||
// A host with no port is the mistake everyone makes once, and 993 is the answer
|
||||
// for every host worth naming.
|
||||
if !strings.Contains(body.Host, ":") {
|
||||
body.Host += ":993"
|
||||
}
|
||||
|
||||
account := imap.Account{
|
||||
Host: body.Host, Username: body.Username,
|
||||
Password: body.Password, Folder: body.Folder,
|
||||
}
|
||||
client, err := imap.Dial(account)
|
||||
if err != nil {
|
||||
s.upstream(w, r, CodeMailboxFailed, "connect mailbox", err)
|
||||
return
|
||||
}
|
||||
client.Close()
|
||||
|
||||
user := userFrom(r.Context())
|
||||
m := &store.Mailbox{
|
||||
UserID: user.ID, Kind: "imap", Name: strings.TrimSpace(body.Name),
|
||||
Host: body.Host, Username: body.Username,
|
||||
Password: body.Password, Folder: body.Folder,
|
||||
}
|
||||
if m.Name == "" {
|
||||
m.Name = body.Username
|
||||
}
|
||||
if err := s.db.SaveMailbox(r.Context(), m); err != nil {
|
||||
// The unique index. Saying which mailbox is already here beats "constraint
|
||||
// failed", which is true and useless.
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
writeError(w, http.StatusConflict, CodeValidation,
|
||||
fmt.Sprintf("%s on %s is already connected", body.Username, body.Host))
|
||||
return
|
||||
}
|
||||
s.internal(w, r, "save mailbox", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.DeleteMailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID")); err != nil {
|
||||
s.internal(w, r, "delete mailbox", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSyncMailbox reads one mailbox now, within a budget of its own.
|
||||
func (s *Server) handleSyncMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
m, err := s.db.Mailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "read mailbox", err)
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such mailbox")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), defaultMailboxSyncBudget)
|
||||
defer cancel()
|
||||
|
||||
created, err := s.SyncMailbox(ctx, user, m)
|
||||
if err != nil {
|
||||
// Whatever was made by the deadline is kept: each task is committed as it
|
||||
// is made, so a short budget is a shorter run rather than a lost one.
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeMailboxFailed, "sync mailbox", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"created": created})
|
||||
}
|
||||
|
||||
// SyncMailbox turns the flagged mail newer than the last run into tasks.
|
||||
//
|
||||
// Exported because the background job calls it too, on the same terms and without
|
||||
// a budget: nobody is waiting there.
|
||||
func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mailbox) (int, error) {
|
||||
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
||||
// releasing before the marker is written would leave exactly the gap this is
|
||||
// here to close.
|
||||
unlock := s.lockSync(m.ID)
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock. The row that came in may have been fetched before the
|
||||
// run that just finished moved the marker.
|
||||
if fresh, err := s.db.Mailbox(ctx, m.UserID, m.ID); err == nil && fresh != nil {
|
||||
m = fresh
|
||||
}
|
||||
|
||||
client, err := imap.Dial(imap.Account{
|
||||
Host: m.Host, Username: m.Username, Password: m.Password, Folder: m.Folder,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
messages, searched, err := client.Since(m.LastUID, 25)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
projectID, err := s.db.InboxID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
// below has to be written for it: a run that created tasks and forgot how
|
||||
// far it got would make them all again on the next pass.
|
||||
break
|
||||
}
|
||||
|
||||
subject := msg.Subject
|
||||
if subject == "" {
|
||||
subject = "(uden emne)"
|
||||
}
|
||||
content := subject
|
||||
if msg.From != "" {
|
||||
content = msg.From + ": " + subject
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID,
|
||||
Content: content,
|
||||
Description: msg.Snippet,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// The flag comes off what became a task. This is what actually stops a mail
|
||||
// arriving twice; the marker below is now a second line rather than the only
|
||||
// one. See imap.Unflag for why the record belongs in the mailbox and not here.
|
||||
//
|
||||
// Outside the request's context, like the marker: a cancelled request that
|
||||
// already created tasks must still take the flags off them, or the next run
|
||||
// makes them all again.
|
||||
if err := client.Unflag(done); err != nil {
|
||||
// Not fatal, and not the caller's problem. A folder somebody opened
|
||||
// read-only, or a server that refuses the STORE, still gets the uid marker
|
||||
// underneath — worse, but not broken.
|
||||
s.log.Warn("could not unflag imported mail", "err", err, "mailbox", m.ID, "count", len(done))
|
||||
}
|
||||
|
||||
// A run that got all the way through moves the marker to the whole of what the
|
||||
// search considered, not merely to the last message it happened to read. That
|
||||
// is what stops the same mail becoming a task again on the next run when the
|
||||
// server did not put a uid in the fetch reply.
|
||||
if ctx.Err() == nil && searched > highest {
|
||||
highest = searched
|
||||
}
|
||||
|
||||
// Written even when the loop was cut short, and outside the request's context
|
||||
// so a cancelled request still records what it did.
|
||||
if highest > m.LastUID {
|
||||
if err := s.db.MarkMailboxRead(context.WithoutCancel(ctx), m.ID, highest, time.Now()); err != nil {
|
||||
s.log.Warn("mark mailbox read", "err", err, "mailbox", m.ID)
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// lockSync serialises one thing that polls against itself and returns the release.
|
||||
//
|
||||
// A mailbox to begin with; a calendar connection now takes the same lock under a
|
||||
// key of its own. Two runs at once — somebody pressing "fetch now" while the sweep
|
||||
// is in the middle of the same thing — both start from the state as it was, and
|
||||
// the loser writes last. For a mailbox that made forty copies of one mail in a
|
||||
// second; for a calendar it would be a window deleted after it was refilled.
|
||||
func (s *Server) lockSync(id string) func() {
|
||||
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
@@ -101,6 +101,7 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -154,7 +155,7 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "mcp key", err)
|
||||
s.internal(w, r, "mcp key", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||
@@ -496,3 +497,168 @@ func clamp(value, fallback, max int) int {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// --- notes ---------------------------------------------------------------------
|
||||
|
||||
// registerNoteTools adds the note half of the MCP surface.
|
||||
//
|
||||
// Separate from the task tools for one reason worth stating: this is how a note
|
||||
// gets in from somewhere else. Apple Notes has no export worth the name, but a
|
||||
// model running on the machine can read Notes.app and write what it finds through
|
||||
// these — one note at a time, with the text as Markdown. That is a migration
|
||||
// nobody has to build a parser for.
|
||||
func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_notes",
|
||||
Description: "Find notes by text. Matches title and body, and is generous about " +
|
||||
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
|
||||
"With no query it returns the most recent notes. This is the tool for " +
|
||||
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("Words to look for in the title and body."),
|
||||
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
|
||||
}),
|
||||
}, s.mcpSearchNotes)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_note",
|
||||
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
|
||||
"also exactly what an export produces, so nothing is converted and nothing " +
|
||||
"is lost. Two things in the text are read out of it and become links: " +
|
||||
"`#Projekt` files the note against that project, so it appears on the " +
|
||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
||||
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||
}),
|
||||
}, s.mcpCreateNote)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_note",
|
||||
Description: "Change a note. Only the fields given are changed. Sending `body` " +
|
||||
"replaces the whole text, so read the note first if you mean to add to it — " +
|
||||
"and the links are read again from the new text, so a #tag removed is a link " +
|
||||
"removed.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"note_id": mcp.Str("Which note."),
|
||||
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||
}),
|
||||
}, s.mcpUpdateNote)
|
||||
}
|
||||
|
||||
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Text string `json:"text"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
limit := params.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
var notes []store.Note
|
||||
var err error
|
||||
if strings.TrimSpace(params.Text) == "" {
|
||||
notes, err = s.db.LooseNotes(ctx, userID)
|
||||
} else {
|
||||
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The same filter the HTTP surface applies, for the same reason: a search must
|
||||
// not become a way to read what you cannot open.
|
||||
out := make([]map[string]any, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if !s.mcpMayRead(ctx, &n, userID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": n.ID, "title": n.Title, "body": n.Body,
|
||||
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
|
||||
})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return map[string]any{"notes": out, "count": len(out)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == userID
|
||||
}
|
||||
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
Body string `json:"body"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n := &store.Note{
|
||||
CreatedBy: userID,
|
||||
Body: params.Body,
|
||||
Pinned: params.Pinned,
|
||||
}
|
||||
if params.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ProjectID = params.ProjectID
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The links are reported back rather than left silent. A note whose #tag was
|
||||
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
|
||||
// the row that was written.
|
||||
return map[string]any{
|
||||
"id": n.ID, "title": n.Title, "links": n.Links,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
var params struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
_ = json.Unmarshal(args, ¶ms)
|
||||
|
||||
n, err := s.db.Note(ctx, params.NoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == nil || !s.mcpMayRead(ctx, n, userID) {
|
||||
return nil, fmt.Errorf("no such note")
|
||||
}
|
||||
if n.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Body != nil {
|
||||
n.Body = *params.Body
|
||||
}
|
||||
if params.Pinned != nil {
|
||||
n.Pinned = *params.Pinned
|
||||
}
|
||||
if err := s.db.SaveNote(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
// as everybody being logged out at once — the frontend clears its state
|
||||
// and shows the sign-in screen — and nothing in the logs says why.
|
||||
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "authenticate", err)
|
||||
s.internal(w, r, "authenticate", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// Searching and listing are the same endpoint, because they answer the same
|
||||
// question with different words. ?q= searches; ?project= narrows; neither gives
|
||||
// the loose ones.
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
|
||||
found, err := s.db.SearchNotes(r.Context(), q, 50)
|
||||
if err != nil {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
return
|
||||
}
|
||||
|
||||
if projectID := r.URL.Query().Get("project"); projectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
|
||||
s.storeError(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
found, err := s.db.NotesInProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
return
|
||||
}
|
||||
|
||||
// Everything they can see, not only the loose ones. Filing a note in a project
|
||||
// is how it gets shared, and a list that dropped it at that moment made sharing
|
||||
// look like deleting.
|
||||
found, err := s.db.AllNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
}
|
||||
|
||||
// readable drops what this person may not see.
|
||||
//
|
||||
// Filtered after the search rather than inside it: FTS5 answers about text, and
|
||||
// teaching the index about roles would put an access rule in a place nobody would
|
||||
// think to look when it was wrong.
|
||||
func (s *Server) readable(r *http.Request, notes []store.Note) []store.Note {
|
||||
out := make([]store.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if s.mayTouchNote(r, &n, store.RoleViewer) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// The same answer for "no such note" and "not yours", so an id cannot be probed.
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
// No title. It is the note's first line and is derived on every save — a field
|
||||
// here would be a promise this server cannot keep.
|
||||
type noteBody struct {
|
||||
ProjectID *string `json:"project_id"`
|
||||
Body *string `json:"body"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID}
|
||||
if body.ProjectID != nil && *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, user.ID, store.RoleEditor); err != nil {
|
||||
s.storeError(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
if body.Pinned != nil {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, n)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
s.storeError(w, r, "move note", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
if body.Pinned != nil {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteNote(r.Context(), n.ID); err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleNotesLinking answers the backwards question for a task or a project:
|
||||
// what has somebody written about this.
|
||||
func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
|
||||
kind := chi.URLParam(r, "kind")
|
||||
if kind != "task" && kind != "project" && kind != "note" {
|
||||
writeError(w, http.StatusBadRequest, CodeValidation, "kind must be task, project or note")
|
||||
return
|
||||
}
|
||||
found, err := s.db.NotesLinking(r.Context(), kind, chi.URLParam(r, "targetID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "notes linking", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
}
|
||||
|
||||
// handleExportNotes writes every note this person can see as a zip of Markdown
|
||||
// files, one per note.
|
||||
//
|
||||
// A zip of plain files rather than one bundle in a format of our own: what comes
|
||||
// out is what was stored, and it opens in Obsidian, in a text editor, in anything.
|
||||
// That is the promise the whole design was arranged around — the note on disk is
|
||||
// already the file you would export — and this is where it is either kept or not.
|
||||
func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
notes, err := s.db.AllNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export notes", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
// Names collide: two notes called "Møde" are two notes, and a zip with one entry
|
||||
// twice is a zip that loses one of them.
|
||||
used := map[string]int{}
|
||||
for _, n := range notes {
|
||||
name := noteFilename(n.Title)
|
||||
used[name]++
|
||||
if used[name] > 1 {
|
||||
name = fmt.Sprintf("%s (%d)", name, used[name])
|
||||
}
|
||||
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: name + ".md",
|
||||
Method: zip.Deflate,
|
||||
Modified: n.UpdatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
// The response is already streaming, so there is no status left to send.
|
||||
// Stopping leaves a truncated zip, which a zip tool says is truncated —
|
||||
// better than a complete-looking file with a note missing from it.
|
||||
s.log.Error("export notes", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(f, n.Body); err != nil {
|
||||
s.log.Error("export notes", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noteFilename makes a title safe to be a filename on any of the three systems
|
||||
// people actually use, without turning it into something unrecognisable.
|
||||
func noteFilename(title string) string {
|
||||
name := strings.TrimSpace(title)
|
||||
if name == "" {
|
||||
name = "uden titel"
|
||||
}
|
||||
// Windows refuses these outright; a slash would make directories on the others.
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if strings.ContainsRune(`/\:*?"<>|`, r) || r < 0x20 {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
// Long titles are the first line of a note, which can be a whole sentence.
|
||||
if len([]rune(name)) > 80 {
|
||||
name = string([]rune(name)[:80])
|
||||
}
|
||||
return strings.TrimRight(name, " .")
|
||||
}
|
||||
|
||||
// maxNoteImportBytes bounds one upload. Generous for text — a decade of notes is
|
||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||
const maxNoteImportBytes = 64 << 20
|
||||
|
||||
// What the archive may become once it is unpacked, and how many entries it may
|
||||
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
|
||||
// nothing at all about what it expands to.
|
||||
const (
|
||||
maxNoteImportUnpacked = 2 << 30
|
||||
maxNoteImportFiles = 20_000
|
||||
)
|
||||
|
||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
||||
//
|
||||
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||
// this program goes back into it. It is also the way in from anywhere else, since
|
||||
// a folder of Markdown is what Obsidian, Bear, iA Writer and a shell script all
|
||||
// produce — and what the Apple Notes exporter in tools/ writes.
|
||||
//
|
||||
// Filenames are ignored. The title is the first line of the body, everywhere else
|
||||
// in this program, and making it the filename here would be a second rule for the
|
||||
// same thing that disagrees the moment somebody renames a file.
|
||||
func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxNoteImportBytes)
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "the file is too large")
|
||||
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()
|
||||
|
||||
zr, err := zip.NewReader(file.(io.ReaderAt), header.Size)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "not a zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
created, skipped, files := 0, 0, 0
|
||||
|
||||
// Two passes. Everything that is not Markdown is stored first, so that when a
|
||||
// note is read its pictures already have addresses to be rewritten to — a note
|
||||
// and its images arrive in whatever order the archive happens to hold them, and
|
||||
// one pass would leave half the links pointing at nothing.
|
||||
// The archive as a whole, not just the upload.
|
||||
//
|
||||
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
|
||||
// MB can become sixty-six gigabytes on the way out — written into the same
|
||||
// volume the database lives on. The upload limit says nothing about that, and
|
||||
// nothing else did either.
|
||||
//
|
||||
// Two ceilings: one per file, and one across the archive. A single enormous
|
||||
// photograph and ten thousand small ones are different attacks with the same
|
||||
// ending.
|
||||
if len(zr.File) > maxNoteImportFiles {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that archive holds too many files")
|
||||
return
|
||||
}
|
||||
budget := int64(maxNoteImportUnpacked)
|
||||
|
||||
stored := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// The header's own claim first, which costs nothing to check and rejects
|
||||
// the obvious case before a byte is written. It is only a claim, so the
|
||||
// reader below is limited as well.
|
||||
if f.UncompressedSize64 > maxUploadBytes {
|
||||
s.log.Warn("import: a file in the archive is too large", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
s.log.Warn("import note file", "err", err, "file", name)
|
||||
continue
|
||||
}
|
||||
if size > maxUploadBytes || size > budget {
|
||||
// Over the line once it was actually read: remove what was written
|
||||
// rather than keeping a truncated file that would open as nothing.
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
|
||||
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
budget -= size
|
||||
stored[name] = hashed
|
||||
files++
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// Counted, not passed over in silence. A Markdown file the filter refuses is
|
||||
// a note that did not arrive, and the caller is told the same number either
|
||||
// way unless it is said here — which is how one note went missing without
|
||||
// anything looking wrong.
|
||||
if skipFile(name) {
|
||||
s.log.Warn("import skipped a note file", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(rc, 2<<20))
|
||||
rc.Close()
|
||||
if err != nil || strings.TrimSpace(string(body)) == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
text, wrote, changed := readFrontMatter(string(body))
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: text}
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("import note", "err", err, "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// After the save, not as part of it: SaveNote owns `updated_at` and is right
|
||||
// to, so the dates a note had before this program existed are written over
|
||||
// the top rather than passed through it.
|
||||
if !wrote.IsZero() || !changed.IsZero() {
|
||||
if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil {
|
||||
s.log.Warn("import note dates", "err", err, "file", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The pictures the note referred to, now that it has an id to hang them on.
|
||||
// The text is rewritten to the addresses they were given, so an image that
|
||||
// sat in the middle of a paragraph is still in the middle of it.
|
||||
if rewritten, attached := s.attachToNote(r, n, path.Dir(name), stored, zr); attached > 0 {
|
||||
n.Body = rewritten
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("rewrite note links", "err", err, "note", n.ID)
|
||||
}
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"created": created, "skipped": skipped, "files": files,
|
||||
})
|
||||
}
|
||||
|
||||
// skipFile is the things in an archive that are not content: directories' own
|
||||
// entries, macOS resource forks, and anything trying to climb out of the archive.
|
||||
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
||||
// would double a whole library with each copy full of binary rubbish.
|
||||
func skipFile(name string) bool {
|
||||
if strings.HasPrefix(name, "__MACOSX/") ||
|
||||
strings.HasPrefix(path.Base(name), "._") ||
|
||||
strings.HasPrefix(path.Base(name), ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Traversal, read as path segments rather than as a substring.
|
||||
//
|
||||
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
|
||||
// wrong thing: it does not describe a path that climbs out of the archive, it
|
||||
// describes any name with two dots next to each other anywhere in it. A note
|
||||
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
|
||||
// silently, and without even counting as skipped, so the number the import
|
||||
// reported still looked right. One note in twelve hundred, found only by
|
||||
// counting what came out.
|
||||
//
|
||||
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
|
||||
// starts at the root, is not — and those are what a zip uses to write outside
|
||||
// the folder it was unpacked into.
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return true
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// attachToNote records the files a note's text points at and rewrites the text to
|
||||
// where they now live. Returns the new body and how many were attached.
|
||||
//
|
||||
// Only the files this note refers to. An archive is one folder for many notes, and
|
||||
// hanging every picture on every note would turn a library of a hundred into ten
|
||||
// thousand attachments.
|
||||
func (s *Server) attachToNote(
|
||||
r *http.Request, n *store.Note, dir string, stored map[string]string, zr *zip.Reader,
|
||||
) (string, int) {
|
||||
body := n.Body
|
||||
attached := 0
|
||||
|
||||
for _, f := range zr.File {
|
||||
hashed, ok := stored[f.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
base := path.Base(f.Name)
|
||||
// The three ways a Markdown file names a neighbouring image: by its path
|
||||
// inside the archive, by the path relative to the note, and by its bare name.
|
||||
//
|
||||
// The archive path was missing, and it is the one an Apple Notes export
|
||||
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||
// root beside the folder. Only the bare name matched, so only the bare name
|
||||
// was replaced, and every picture in every imported note ended up pointing at
|
||||
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||
// stored and attached correctly; it was the link that was wrong.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
refs := []string{f.Name, relative, base}
|
||||
if !strings.Contains(body, f.Name) &&
|
||||
!strings.Contains(body, relative) &&
|
||||
!strings.Contains(body, base) {
|
||||
continue
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
Filename: base,
|
||||
MimeType: mimeOf(base),
|
||||
Size: int64(f.UncompressedSize64),
|
||||
Path: hashed,
|
||||
UploadedBy: n.CreatedBy,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
s.log.Warn("attach to note", "err", err, "file", base)
|
||||
continue
|
||||
}
|
||||
|
||||
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||
// stående foran adressen, og linket peger på ingenting.
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||
for _, ref := range refs {
|
||||
if ref != "" {
|
||||
body = strings.ReplaceAll(body, ref, url)
|
||||
}
|
||||
}
|
||||
attached++
|
||||
}
|
||||
return body, attached
|
||||
}
|
||||
|
||||
// readFrontMatter takes the YAML block off the top of a Markdown file and reads
|
||||
// the two dates out of it, returning the rest of the text.
|
||||
//
|
||||
// Not a YAML parser, and deliberately not: the block this reads is the one this
|
||||
// program writes, plus whatever Obsidian and Bear write, and all of them use the
|
||||
// same handful of `key: value` lines. Pulling in a parser to read two dates would
|
||||
// be a dependency that can fail in ways nobody here would recognise.
|
||||
//
|
||||
// A file with no front matter comes back untouched — that is the normal case, and
|
||||
// it must never lose its first line to a rule about a block that is not there.
|
||||
func readFrontMatter(body string) (text string, created, modified time.Time) {
|
||||
if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") {
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
rest := body[strings.Index(body, "\n")+1:]
|
||||
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
// An opening fence with no close is not front matter; it is a horizontal
|
||||
// rule somebody wrote at the top of their note.
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
block := rest[:end]
|
||||
after := rest[end+len("\n---"):]
|
||||
after = strings.TrimPrefix(after, "\r")
|
||||
after = strings.TrimPrefix(after, "\n")
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "created", "created_at", "date":
|
||||
created = parseNoteTime(value)
|
||||
case "modified", "updated", "updated_at":
|
||||
modified = parseNoteTime(value)
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(after, "\n"), created, modified
|
||||
}
|
||||
|
||||
// parseNoteTime accepts the shapes these files actually carry. A date without a
|
||||
// time is that day at midnight, local — the note was written on that day, and
|
||||
// pretending to know the hour would be worse than not having one.
|
||||
func parseNoteTime(v string) time.Time {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
} {
|
||||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// mimeOf guesses from the extension. The archive does not carry a type, and
|
||||
// sniffing the bytes would mean reading every file twice.
|
||||
func mimeOf(name string) string {
|
||||
if t := mime.TypeByExtension(path.Ext(name)); t != "" {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// handleUploadNoteAttachment takes a file into a note.
|
||||
//
|
||||
// The import could already do this, and nothing else could: a note brought in
|
||||
// from Apple Notes arrived with its pictures, and a note written here could not
|
||||
// be given one. Pasting a screenshot into a note is the most ordinary thing there
|
||||
// is, and it did nothing at all.
|
||||
//
|
||||
// Editor rights on the note, checked the same way every other write to it is. The
|
||||
// answer for "no such note" and "not yours" is the same, so an id cannot be
|
||||
// probed by watching which one comes back.
|
||||
func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
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
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
|
||||
//
|
||||
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
|
||||
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
|
||||
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
|
||||
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
|
||||
// tælle efter, hvor mange der kom ud.
|
||||
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
|
||||
keep := []string{
|
||||
"Så blev det endelig jul! Stay tuned... ☕️.md",
|
||||
"Møde 12.8..md",
|
||||
"vedhaeftninger/1-2-Pasted Graphic.png",
|
||||
"noter/Referat....md",
|
||||
}
|
||||
for _, name := range keep {
|
||||
if skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
|
||||
}
|
||||
}
|
||||
|
||||
drop := []string{
|
||||
"../uden for.md",
|
||||
"noter/../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"__MACOSX/._noget",
|
||||
".skjult.md",
|
||||
"noter/._AppleDouble.md",
|
||||
}
|
||||
for _, name := range drop {
|
||||
if !skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// En zip-bombe skal ikke kunne skrive gigabyte ned på den disk, databasen ligger
|
||||
// på.
|
||||
//
|
||||
// Uploaden er begrænset til 64 MB, og det siger ingenting om, hvad de 64 MB
|
||||
// bliver til: deflate når omkring 1032:1, så de kan pakkes ud til seksogtres
|
||||
// gigabyte. Prøven bygger en rigtig lille bombe — fire megabyte nuller, som
|
||||
// pakker til nogle få kilobyte — og ser efter, at loftet på det udpakkede holder.
|
||||
//
|
||||
// Skrevet imod tallene frem for imod endepunktet, fordi det, der skal holde, er
|
||||
// forholdet: en fil, hvis udpakkede størrelse overstiger loftet, må ikke læses
|
||||
// færdig.
|
||||
func TestAZipBombIsRefusedByItsUnpackedSize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("bombe.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nuller pakker næsten helt væk. Fire megabyte er nok til at vise forholdet
|
||||
// uden at prøven selv bruger en gigabyte.
|
||||
if _, err := w.Write(make([]byte, 4<<20)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
packed := int64(buf.Len())
|
||||
if packed > 64<<10 {
|
||||
t.Fatalf("prøvens egen bombe pakkede kun til %d bytes; den viser ikke noget", packed)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), packed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := zr.File[0]
|
||||
if f.UncompressedSize64 != 4<<20 {
|
||||
t.Fatalf("udpakket størrelse = %d", f.UncompressedSize64)
|
||||
}
|
||||
|
||||
// Det, importen gør: læs højst det, budgettet tillader, plus én byte — så en
|
||||
// fil, der er præcis på grænsen, kan skelnes fra en, der er over.
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rc.Close()
|
||||
budget := int64(1 << 20)
|
||||
n, err := io.Copy(io.Discard, io.LimitReader(rc, budget+1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n <= budget {
|
||||
t.Fatalf("læste %d bytes af en fil på 4 MB med et budget på %d — loftet fanger den ikke", n, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// Tallene skal stå i et forhold, der giver mening: en enkelt fil må aldrig kunne
|
||||
// bruge hele arkivets budget, og arkivet må ikke være mindre end én fil.
|
||||
func TestTheImportCeilingsAgreeWithEachOther(t *testing.T) {
|
||||
if maxNoteImportUnpacked <= maxUploadBytes {
|
||||
t.Error("arkivets budget er ikke større end én enkelt fil")
|
||||
}
|
||||
if maxNoteImportFiles < 2000 {
|
||||
t.Error("filloftet er under det, en rigtig Apple Noter-eksport indeholder")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A note in a project is the project's; a note in no project is its author's.
|
||||
// Both answers come from the project's own roles rather than from a rule notes
|
||||
// invented for themselves, which is the point of filing them there.
|
||||
func TestANoteIsNotReadableByJustAnybody(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Noget privat om #Firma",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
id, _ := body["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("no id came back")
|
||||
}
|
||||
|
||||
// The tags were read out of the text on the way in, without being asked for.
|
||||
links, _ := body["links"].([]any)
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("the note recorded %v", body["links"])
|
||||
}
|
||||
|
||||
// Somebody else gets the same answer as for a note that does not exist, so an
|
||||
// id cannot be probed for existence.
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Searching must not become a way to read what you cannot open. The filter is
|
||||
// applied after the index, and this is what says so.
|
||||
func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Hemmeligt kodeord til hjemmesiden",
|
||||
}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
_, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
||||
if got := len(mine["notes"].([]any)); got != 1 {
|
||||
t.Fatalf("the author found %d of their own notes", got)
|
||||
}
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
_, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
||||
if got := len(theirs["notes"].([]any)); got != 0 {
|
||||
t.Errorf("a stranger's search returned %d notes", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The backwards question, which is what makes notes worth linking at all.
|
||||
func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ring til Anders",
|
||||
})
|
||||
taskID, _ := task["id"].(string)
|
||||
if taskID == "" {
|
||||
t.Fatalf("no task id: %v", task)
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
||||
"body": "Aftalt at han ringer først, se /opgave/" + taskID,
|
||||
}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatal("the note was not written")
|
||||
}
|
||||
|
||||
_, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil)
|
||||
notes, _ := found["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("%d notes point at the task, want 1", len(notes))
|
||||
}
|
||||
}
|
||||
|
||||
// Editing the text moves the links with it. A link that outlives its mention
|
||||
// claims a connection that is not there.
|
||||
func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"})
|
||||
id, _ := note["id"].(string)
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if len(before["notes"].([]any)) != 1 {
|
||||
t.Fatal("the first save recorded nothing")
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{
|
||||
"body": "Om #Regnskab i stedet",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("the note was not changed")
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if got := len(after["notes"].([]any)); got != 0 {
|
||||
t.Errorf("%d notes still point at Firma", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The export is the promise the whole design was arranged around: the note on
|
||||
// disk is already the file you would export, so nothing is converted on the way
|
||||
// out and nothing can be lost in the conversion.
|
||||
func TestNotesExportAsMarkdownFiles(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
bodies := []string{
|
||||
"# Møde med Anders\n\nHan vil gerne have **kaffe** hver uge.",
|
||||
"Slash/i/titlen\n\nog noget tekst",
|
||||
"# Møde med Anders\n\nen anden note med samme titel",
|
||||
}
|
||||
for _, body := range bodies {
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/notes.zip", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("export: status %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
|
||||
t.Errorf("content type is %q", ct)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("the archive does not open: %v", err)
|
||||
}
|
||||
if len(zr.File) != 3 {
|
||||
t.Fatalf("the archive holds %d files, want 3", len(zr.File))
|
||||
}
|
||||
|
||||
byName := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
byName[f.Name] = string(text)
|
||||
}
|
||||
|
||||
// Exactly what was stored, byte for byte. A converted export is an export that
|
||||
// can be wrong.
|
||||
if got := byName["Møde med Anders.md"]; got != bodies[0] {
|
||||
t.Errorf("the note came out as %q", got)
|
||||
}
|
||||
// A slash in a title would make a directory, and on Windows it is refused
|
||||
// outright.
|
||||
if _, ok := byName["Slash-i-titlen.md"]; !ok {
|
||||
t.Errorf("the awkward title became %v", keysOf(byName))
|
||||
}
|
||||
// Two notes with the same title are two notes; one entry twice is one lost.
|
||||
if _, ok := byName["Møde med Anders (2).md"]; !ok {
|
||||
t.Errorf("the second note of the same name is missing: %v", keysOf(byName))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Out and back in again. If an export cannot be imported, it is not an export —
|
||||
// it is a file somebody hopes will be readable later.
|
||||
func TestNotesSurviveTheRoundTripThroughAZip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
files := map[string]string{
|
||||
"Møde med Anders.md": "# Møde med Anders\n\nHan vil have **kaffe** hver uge. #Firma",
|
||||
"noget andet.md": "Løse tanker\n\nog en linje til",
|
||||
// A zip made on a Mac carries these beside every file. Importing them would
|
||||
// double the whole library, with each copy full of binary rubbish.
|
||||
"__MACOSX/._Møde med Anders.md": "\x00\x05\x16\x07binært",
|
||||
"billede.png": "ikke en note",
|
||||
"tom.md": " \n ",
|
||||
}
|
||||
for name, body := range files {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("import: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct{ Created, Skipped int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 {
|
||||
t.Fatalf("imported %d notes, want the 2 real ones", out.Created)
|
||||
}
|
||||
|
||||
// The title came from the first line, not from the filename — which is the same
|
||||
// rule as everywhere else, and the reason renaming a file cannot rename a note.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes", nil)
|
||||
titles := map[string]bool{}
|
||||
for _, raw := range listed["notes"].([]any) {
|
||||
titles[raw.(map[string]any)["title"].(string)] = true
|
||||
}
|
||||
if !titles["Møde med Anders"] || !titles["Løse tanker"] {
|
||||
t.Errorf("the titles came out as %v", titles)
|
||||
}
|
||||
|
||||
// And the #tag was read on the way in, so an imported note lands on its project
|
||||
// exactly as a typed one does.
|
||||
_, linked := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
||||
if len(linked["notes"].([]any)) != 1 {
|
||||
t.Errorf("the imported note did not link to its project")
|
||||
}
|
||||
}
|
||||
|
||||
// A note from Apple Notes is full of photographs. One that arrives as words alone
|
||||
// is not that note — it is a summary of it.
|
||||
func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
write := func(name, body string) {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(f, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("noter/Kvittering.md", "# Kvittering\n\nHer er den:\n\n\n\nog resten af teksten")
|
||||
write("noter/vedhaeftninger/bon.png", "\x89PNG\r\n\x1a\nlad som om")
|
||||
// Another note in the same archive, which must not inherit the first one's
|
||||
// picture: an archive is one folder for many notes.
|
||||
write("noter/Uden billede.md", "Uden billede\n\nbare tekst")
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
part, _ := mw.CreateFormFile("file", "noter.zip")
|
||||
part.Write(archive.Bytes())
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out struct{ Created, Skipped, Files int }
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Created != 2 || out.Files != 1 {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
if strings.Contains(body, "vedhaeftninger/bon.png") {
|
||||
t.Errorf("the link still points into the archive: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "/api/v1/attachments/") {
|
||||
t.Errorf("the picture was not attached: %q", body)
|
||||
}
|
||||
// And it is still where it was written, in the middle of the note.
|
||||
if !strings.HasPrefix(body, "# Kvittering") || !strings.Contains(body, "og resten af teksten") {
|
||||
t.Errorf("the note was rearranged: %q", body)
|
||||
}
|
||||
|
||||
// The picture is really there and really readable.
|
||||
id := body[strings.Index(body, "/api/v1/attachments/")+len("/api/v1/attachments/"):]
|
||||
id = strings.SplitN(id, ")", 2)[0]
|
||||
got, _ := ts.do(t, "GET", "/api/v1/attachments/"+id, nil)
|
||||
if got.StatusCode != http.StatusOK {
|
||||
t.Errorf("the attachment answers %d", got.StatusCode)
|
||||
}
|
||||
|
||||
// The other note did not inherit it.
|
||||
_, other := ts.do(t, "GET", "/api/v1/notes?q=Uden", nil)
|
||||
for _, raw := range other["notes"].([]any) {
|
||||
if strings.Contains(raw.(map[string]any)["body"].(string), "attachments/") {
|
||||
t.Error("a note that mentioned no picture was given one")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/push"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -27,12 +29,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
||||
if err != nil {
|
||||
s.internal(w, "list notifications", err)
|
||||
s.internal(w, r, "list notifications", err)
|
||||
return
|
||||
}
|
||||
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count notifications", err)
|
||||
s.internal(w, r, "count notifications", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
||||
if err != nil {
|
||||
s.internal(w, "mark notifications read", err)
|
||||
s.internal(w, r, "mark notifications read", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -98,6 +100,12 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
|
||||
}
|
||||
}
|
||||
|
||||
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||
// which has the schedule but not the keys.
|
||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
s.pushToUser(userID, title, body, projectID)
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||
defer cancel()
|
||||
@@ -148,7 +156,7 @@ type pushSubscriptionRequest struct {
|
||||
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||
public, _, err := s.vapidKeys(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "vapid keys", err)
|
||||
s.internal(w, r, "vapid keys", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
||||
@@ -164,6 +172,23 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The endpoint is an address this server will POST to, on a schedule the
|
||||
// caller chooses — set a reminder a minute out and the push job fetches it,
|
||||
// again every minute. Unchecked, that is a way to make the instance reach
|
||||
// anything it can route to, on demand.
|
||||
//
|
||||
// https only: a push service is on the public internet and speaks TLS. Nothing
|
||||
// legitimate needs http here, and allowing it is the difference between a
|
||||
// blind request and a readable one.
|
||||
if !strings.HasPrefix(req.Endpoint, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": "must be an https address"})
|
||||
return
|
||||
}
|
||||
if reason := safedial.CheckURL(req.Endpoint); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": reason})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
||||
UserID: userFrom(r.Context()).ID,
|
||||
Endpoint: req.Endpoint,
|
||||
@@ -172,7 +197,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
UserAgent: r.UserAgent(),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "save push subscription", err)
|
||||
s.internal(w, r, "save push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -183,8 +208,11 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||
s.internal(w, "delete push subscription", err)
|
||||
// Scoped to the caller. It was not, so anybody who learned somebody else's
|
||||
// endpoint could unsubscribe their device — a small thing to do to somebody
|
||||
// and no reason at all to be able to.
|
||||
if err := s.db.DeletePushSubscription(r.Context(), userFrom(r.Context()).ID, req.Endpoint); err != nil {
|
||||
s.internal(w, r, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Asking the panel to restart this instance.
|
||||
//
|
||||
// A container cannot replace its own image from the inside, so "update" is really
|
||||
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
|
||||
// the way, which is what makes a restart an update at all.
|
||||
//
|
||||
// The alternative was a second browser tab: read the version here, go to the
|
||||
// panel, find the server, press Restart. This removes the tab, not the panel's
|
||||
// authority — the panel still does the work and still records who asked.
|
||||
//
|
||||
// Administrators only, and sessions only. A leaked API token that could restart
|
||||
// the server is a denial of service in one request, and the whole point of the
|
||||
// session rule elsewhere in this file is that a stolen token must not be able to
|
||||
// do things only a person at a keyboard should.
|
||||
|
||||
type panelStatusJSON struct {
|
||||
// Configured is all three settings being present. The token itself is never
|
||||
// sent: the page needs to know whether the button can work, not what the
|
||||
// credential is.
|
||||
Configured bool `json:"configured"`
|
||||
PanelURL string `json:"panel_url,omitempty"`
|
||||
// Why it cannot work, when it cannot. An operator reading "not configured"
|
||||
// should not have to go and find out which of three things is missing.
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) panelStatus() panelStatusJSON {
|
||||
var missing []string
|
||||
if s.cfg.PanelURL == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_URL")
|
||||
}
|
||||
if s.cfg.PanelToken == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_TOKEN")
|
||||
}
|
||||
if s.cfg.PanelServerID == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
|
||||
}
|
||||
return panelStatusJSON{
|
||||
Configured: len(missing) == 0,
|
||||
PanelURL: s.cfg.PanelURL,
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.panelStatus())
|
||||
}
|
||||
|
||||
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.panelStatus()
|
||||
if !status.Configured {
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
|
||||
|
||||
// A short timeout of its own. The panel stops this container as part of
|
||||
// answering, so the reply may never arrive — that is not a failure, and a
|
||||
// request that hung waiting for it would look like one.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// safe-restart rather than restart, and the difference is not politeness.
|
||||
//
|
||||
// The plain endpoint does its work while the request is open: it stops the
|
||||
// container — which kills this process — and the panel, seeing the caller
|
||||
// vanish, never gets to the starting half. That took production down and left
|
||||
// it down for four minutes on 18 August, and it cannot be fixed from this side:
|
||||
// any button that synchronously asks for its own death has the same shape.
|
||||
//
|
||||
// safe-restart is scheduled. The panel answers "Restart scheduled" and does the
|
||||
// work on its own afterwards, so nothing depends on this process still being
|
||||
// alive to hear the reply.
|
||||
url := fmt.Sprintf("%s/api/servers/%s/safe-restart", s.cfg.PanelURL, s.cfg.PanelServerID)
|
||||
body := strings.NewReader(`{"backup_first":false,"target_id":""}`)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
s.internal(w, r, "build panel request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
// Including "the panel killed us mid-reply", which is the successful case
|
||||
// wearing a failure's clothes. The interface polls /healthz rather than
|
||||
// believing either answer, so this only has to be honest about what it saw.
|
||||
s.log.Warn("panel restart call did not complete", "err", err)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"restarting": true,
|
||||
"note": "the panel did not answer; it may already be restarting this container",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal,
|
||||
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// A panel that never answers must not become this server's problem. The container
|
||||
// is being asked to stop, so the reply may legitimately never arrive — but the
|
||||
// request has to end either way, or whatever sits in front answers for it.
|
||||
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Drained first. A real server reads what it was sent, and one that does not
|
||||
// never learns the caller has hung up — httptest.Server.Close then waits for
|
||||
// a handler that will never return, and the whole binary times out instead
|
||||
// of the one request.
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "test-token"
|
||||
cfg.PanelServerID = "server-1"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Its own client: the shared one gives up at ten seconds, which is the very
|
||||
// budget under test, so it could not tell a pass from a failure.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s)", err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
took := time.Since(started)
|
||||
|
||||
if took > 20*time.Second {
|
||||
t.Errorf("the restart took %s; that is long enough for a proxy to answer first",
|
||||
took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Errorf("status %d, want 202 — a panel that went quiet is the expected case, "+
|
||||
"not a failure", resp.StatusCode)
|
||||
}
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
// And a panel that says no must say so in this server's own words, not in a proxy's.
|
||||
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "wrong-token"
|
||||
cfg.PanelServerID = "server-1"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
|
||||
}
|
||||
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
|
||||
t.Errorf("the reply does not say what the panel answered: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Forty copies of one mail arrived in a single second because two runs read the
|
||||
// same marker before either wrote a new one — somebody pressing "Fetch now" while
|
||||
// the ten-minute sweep was already inside the same mailbox.
|
||||
//
|
||||
// The lock is what stops it, and a lock is only worth having if something tries
|
||||
// the door: this runs the two concurrently on purpose.
|
||||
func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
// A bare server: the lock needs nothing but its own zero value, and building a
|
||||
// database and a session around it would only make the test slower to read.
|
||||
srv := &Server{}
|
||||
|
||||
var inside, most int
|
||||
var mu sync.Mutex
|
||||
release := make(chan struct{})
|
||||
|
||||
// Stands in for a run: takes the lock, notes how many are inside, and holds it
|
||||
// until told to let go.
|
||||
run := func(done chan<- struct{}) {
|
||||
unlock := srv.lockSync("box-1")
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > most {
|
||||
most = inside
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
<-release
|
||||
|
||||
mu.Lock()
|
||||
inside--
|
||||
mu.Unlock()
|
||||
unlock()
|
||||
done <- struct{}{}
|
||||
}
|
||||
|
||||
first, second := make(chan struct{}), make(chan struct{})
|
||||
go run(first)
|
||||
go run(second)
|
||||
|
||||
// Long enough that both would be inside if nothing kept them apart.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
<-first
|
||||
<-second
|
||||
|
||||
if most != 1 {
|
||||
t.Errorf("%d runs were inside the same mailbox at once; that is how one mail "+
|
||||
"becomes forty tasks", most)
|
||||
}
|
||||
|
||||
// And two different mailboxes must not wait for each other: one slow host is
|
||||
// not everybody's problem.
|
||||
a := srv.lockSync("box-a")
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
srv.lockSync("box-b")()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("a second mailbox waited for the first")
|
||||
}
|
||||
a()
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Passkeys: a key on a device instead of a secret in a head.
|
||||
//
|
||||
// Four endpoints, in two pairs. Registration is done by somebody already signed in
|
||||
// — you cannot be handed a way into an account you have not proved you own.
|
||||
// Logging in is done by somebody who is not, and is the one place here that is
|
||||
// reachable without a session.
|
||||
//
|
||||
// Each ceremony is two requests because the browser has to talk to the
|
||||
// authenticator in between: the server issues a challenge, the device signs it,
|
||||
// the server checks the signature against what it remembers issuing. The challenge
|
||||
// lives in the database rather than in a cookie, because the whole point of it is
|
||||
// that the server chose it and remembers choosing it.
|
||||
|
||||
const challengeTTL = 5 * time.Minute
|
||||
|
||||
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
|
||||
// built. 404 would read as "this version does not have passkeys"; the truth is
|
||||
// that this deployment cannot offer them, which is a different thing to fix.
|
||||
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.passkeys != nil {
|
||||
return true
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
|
||||
return false
|
||||
}
|
||||
|
||||
type passkeyJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// AAGUID identifies the authenticator model, which is what lets the list say
|
||||
// something recognisable beside a key somebody named three months ago.
|
||||
AAGUID string `json:"aaguid,omitempty"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
UserVerified bool `json:"user_verified"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list passkeys", err)
|
||||
return
|
||||
}
|
||||
out := make([]passkeyJSON, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
j := passkeyJSON{
|
||||
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
|
||||
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
|
||||
CreatedAt: k.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if !k.LastUsedAt.IsZero() {
|
||||
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
// Whether this deployment can offer them at all, alongside the ones that exist.
|
||||
// The interface asks one question rather than discovering the answer from a 503
|
||||
// it triggered by offering a button — an address an authenticator refuses is a
|
||||
// deployment fact, not an error somebody caused.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"passkeys": out, "available": s.passkeys != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// --- registering a key ------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Resident keys, and the authenticator asked to verify its owner. Both are
|
||||
// what make this a *passkey* rather than a second factor: only a discoverable
|
||||
// credential can start a login with no email typed, and only a verified one
|
||||
// stands in for the password rather than beside it.
|
||||
creation, session, err := s.passkeys.BeginRegistration(account,
|
||||
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
}),
|
||||
// Excluding what they already have, so a second registration on the same
|
||||
// device replaces nothing and reports itself instead of silently making a
|
||||
// duplicate.
|
||||
webauthn.WithExclusions(account.excludeList()),
|
||||
)
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey registration", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, user.ID, "register", session, creation)
|
||||
}
|
||||
|
||||
type finishRegistrationRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishRegistrationRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
key := &store.Passkey{
|
||||
UserID: user.ID,
|
||||
CredentialID: auth.EncodeCredentialID(credential.ID),
|
||||
PublicKey: credential.PublicKey,
|
||||
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
|
||||
SignCount: credential.Authenticator.SignCount,
|
||||
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
|
||||
UserVerified: credential.Flags.UserVerified,
|
||||
Name: name,
|
||||
}
|
||||
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
|
||||
s.internal(w, r, "store passkey", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, passkeyJSON{
|
||||
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
|
||||
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
|
||||
CreatedAt: key.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// --- signing in with a key ---------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Discoverable: nobody has said who they are yet, and that is the point. The
|
||||
// device knows which account its key belongs to, so there is no email to type
|
||||
// and no list of accounts for anybody to probe.
|
||||
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey login", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, "", "login", session, assertion)
|
||||
}
|
||||
|
||||
type finishLoginRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishLoginRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
// Which account, resolved from the credential the device signed with. The
|
||||
// handler is given the raw id and returns the owner; the library checks the
|
||||
// signature against that owner's stored public key.
|
||||
var matched *store.Passkey
|
||||
credential, err := s.passkeys.ValidateDiscoverableLogin(
|
||||
func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched = key
|
||||
owner, err := s.db.UserByID(r.Context(), key.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
|
||||
}, *session, parsed)
|
||||
if err != nil || matched == nil {
|
||||
// One message, as with a wrong password: telling "no such key" apart from
|
||||
// "bad signature" is a way to learn which credentials exist here.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
// A counter that goes backwards means the credential has been cloned. Not
|
||||
// every authenticator keeps one, so zero on both sides is silence rather than
|
||||
// suspicion — but a real counter that has gone down is the one signal this
|
||||
// design gives, and it is refused rather than logged.
|
||||
if credential.Authenticator.CloneWarning {
|
||||
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
owner, err := s.db.UserByID(r.Context(), matched.UserID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load account", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
|
||||
s.log.Warn("record passkey use", "err", err)
|
||||
}
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
|
||||
// A verified key is both factors at once: the device proved possession and the
|
||||
// person proved they were there, which is exactly what a password plus a code
|
||||
// is for. One that only proved possession still stops at the code.
|
||||
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
|
||||
ttl := s.cfg.SessionTTL
|
||||
if pending {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if pending {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(owner)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
}
|
||||
|
||||
// --- managing them ------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||
return
|
||||
}
|
||||
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID, name); err != nil {
|
||||
s.storeError(w, r, "rename passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "delete passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- shared plumbing -----------------------------------------------------------------
|
||||
|
||||
// passkeyAccount is the library's view of an account, plus the rows behind it.
|
||||
type passkeyAccount struct {
|
||||
*auth.PasskeyUser
|
||||
stored []store.Passkey
|
||||
}
|
||||
|
||||
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
|
||||
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
|
||||
for _, c := range a.PasskeyUser.Credentials {
|
||||
out = append(out, c.Descriptor())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials := make([]webauthn.Credential, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
raw, err := auth.DecodeCredentialID(k.CredentialID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
credentials = append(credentials, webauthn.Credential{
|
||||
ID: raw,
|
||||
PublicKey: k.PublicKey,
|
||||
Authenticator: webauthn.Authenticator{
|
||||
SignCount: k.SignCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
return &passkeyAccount{
|
||||
PasskeyUser: &auth.PasskeyUser{
|
||||
ID: userID, Email: email, Name: name, Credentials: credentials,
|
||||
},
|
||||
stored: keys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// issueChallenge stores the server's side of a ceremony and hands the browser the
|
||||
// options plus the id it will answer with.
|
||||
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
|
||||
encoded, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
s.internal(w, r, "encode webauthn session", err)
|
||||
return
|
||||
}
|
||||
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store challenge", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
|
||||
}
|
||||
|
||||
// takeChallenge reads a challenge once and checks it belongs where it is being
|
||||
// used. A registration challenge answered by a login — or one account's answered
|
||||
// by another — is refused rather than merely failing later.
|
||||
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
|
||||
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"that attempt has expired or was already used; start again")
|
||||
return nil, false
|
||||
}
|
||||
if userID != "" && owner != userID {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
|
||||
return nil, false
|
||||
}
|
||||
var session webauthn.SessionData
|
||||
if err := json.Unmarshal(raw, &session); err != nil {
|
||||
s.internal(w, r, "decode webauthn session", err)
|
||||
return nil, false
|
||||
}
|
||||
return &session, true
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// What can be tested without an authenticator, which is most of the surface that
|
||||
// actually goes wrong.
|
||||
//
|
||||
// The signature itself cannot be produced here — that needs a real device or a
|
||||
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
|
||||
// What is testable is everything around it: who may reach these endpoints, that a
|
||||
// challenge is answerable exactly once, that one account's challenge cannot be
|
||||
// answered by another, and that an expired one is refused. Those are the parts a
|
||||
// mistake would make silently permissive.
|
||||
|
||||
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
|
||||
// A leaked API token must not be able to add its own way in: that would turn a
|
||||
// theft into a tenancy.
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/auth/passkeys"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/begin"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/finish"},
|
||||
} {
|
||||
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beginning a registration hands back options and a challenge id, and the options
|
||||
// carry the things that make this a passkey rather than a second factor.
|
||||
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["challenge_id"] == "" || body["challenge_id"] == nil {
|
||||
t.Fatal("no challenge id came back")
|
||||
}
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
if publicKey == nil {
|
||||
t.Fatalf("no publicKey in the options: %v", options)
|
||||
}
|
||||
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
|
||||
if selection["residentKey"] != "preferred" {
|
||||
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
|
||||
"with no email typed", selection["residentKey"])
|
||||
}
|
||||
if selection["userVerification"] != "preferred" {
|
||||
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
|
||||
"stand in for the password", selection["userVerification"])
|
||||
}
|
||||
// The user handle must be the account id rather than the email: it is written
|
||||
// into the credential and cannot be changed, so an email there would orphan
|
||||
// every key the moment somebody changed their address.
|
||||
user, _ := publicKey["user"].(map[string]any)
|
||||
if user["name"] != "[email protected]" {
|
||||
t.Errorf("user.name = %v", user["name"])
|
||||
}
|
||||
if id, _ := user["id"].(string); id == "" {
|
||||
t.Error("no user handle in the options")
|
||||
}
|
||||
}
|
||||
|
||||
// A challenge is answerable exactly once, and only by whom it was issued to.
|
||||
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
challenge := begun["challenge_id"].(string)
|
||||
|
||||
// Somebody else's challenge, answered by this account. Refused before the
|
||||
// signature is even looked at.
|
||||
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And it is spent: the rightful owner cannot use it either now, because taking
|
||||
// it is what deleted it. That is deliberate — a challenge that survives a
|
||||
// failed attempt is a challenge somebody can keep trying.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Logging in with a key needs no session and says nothing about who exists here.
|
||||
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// No session at all — a fresh client, which is the whole point.
|
||||
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
|
||||
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
challenge := body["challenge_id"].(string)
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
// A discoverable login must not list credentials: that list would say which
|
||||
// keys exist on this instance to anybody who asked.
|
||||
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
|
||||
if list, _ := allowed.([]any); len(list) > 0 {
|
||||
t.Errorf("the login options list %d credentials; a discoverable login must not",
|
||||
len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// A garbage answer is refused with the same message a wrong key would get.
|
||||
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A key belongs to its owner, and nobody else can rename or remove it.
|
||||
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Written straight to the store: registering one properly needs a device.
|
||||
key := &store.Passkey{
|
||||
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
|
||||
}
|
||||
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
|
||||
t.Fatalf("the owner sees %d keys, want 1", len(keys))
|
||||
}
|
||||
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
|
||||
t.Errorf("somebody else sees %d of this account's keys", len(keys))
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what, method string
|
||||
body any
|
||||
}{
|
||||
{"rename", "PATCH", map[string]any{"name": "min nu"}},
|
||||
{"delete", "DELETE", nil},
|
||||
} {
|
||||
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
|
||||
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner renaming: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner deleting: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -17,16 +19,17 @@ import (
|
||||
// that is not yours is not found.
|
||||
|
||||
type projectGroupJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||
return projectGroupJSON{
|
||||
ID: g.ID, Name: g.Name, Color: g.Color,
|
||||
ID: g.ID, Name: g.Name, Color: g.Color, Description: g.Description,
|
||||
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
|
||||
}
|
||||
}
|
||||
@@ -34,7 +37,7 @@ func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list project groups", err)
|
||||
s.internal(w, r, "list project groups", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectGroupJSON, 0, len(groups))
|
||||
@@ -45,9 +48,10 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
type projectGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Description *string `json:"description"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -73,11 +77,14 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
if req.Color != nil {
|
||||
g.Color = *req.Color
|
||||
}
|
||||
if req.Description != nil {
|
||||
g.Description = strings.TrimSpace(*req.Description)
|
||||
}
|
||||
if req.Collapsed != nil {
|
||||
g.Collapsed = *req.Collapsed
|
||||
}
|
||||
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
||||
s.internal(w, "create project group", err)
|
||||
s.internal(w, r, "create project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||||
@@ -105,28 +112,158 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
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, Collapsed: req.Collapsed,
|
||||
Name: req.Name, Color: req.Color, Description: req.Description,
|
||||
Collapsed: req.Collapsed,
|
||||
}); err != nil {
|
||||
s.storeError(w, "update project group", err)
|
||||
s.storeError(w, r, "update project group", err)
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project group", err)
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
||||
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
|
||||
}
|
||||
|
||||
// groupProjectJSON is a project as the group's page lists it: the project, plus
|
||||
// how much is left in it. Open tasks, not all of them — "12" beside a project
|
||||
// somebody finished last year is a number that means nothing.
|
||||
type groupProjectJSON struct {
|
||||
projectJSON
|
||||
OpenTasks int `json:"open_tasks"`
|
||||
}
|
||||
|
||||
// handleGetProjectGroup is the group as a place rather than a heading.
|
||||
//
|
||||
// A heading can carry a name. A page carries what the group *is* — the sentence
|
||||
// that says why these projects are one body of work, the documents that belong to
|
||||
// all of them rather than to any one, and the projects themselves with what is
|
||||
// left in each.
|
||||
//
|
||||
// The projects come with the group rather than being fetched per row, because the
|
||||
// page is nothing without them and two round trips to draw one list is one too
|
||||
// many.
|
||||
func (s *Server) handleGetProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
userID := userFrom(r.Context()).ID
|
||||
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), userID, false)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list projects", err)
|
||||
return
|
||||
}
|
||||
counts, err := s.db.OpenTaskCounts(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "open task counts", err)
|
||||
return
|
||||
}
|
||||
inside := make([]groupProjectJSON, 0, 4)
|
||||
for _, p := range projects {
|
||||
if p.GroupID == groupID {
|
||||
inside = append(inside, groupProjectJSON{
|
||||
projectJSON: toProjectJSON(p), OpenTasks: counts[p.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
files, err := s.db.ListGroupAttachments(r.Context(), groupID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list group attachments", err)
|
||||
return
|
||||
}
|
||||
attachments := make([]attachmentJSON, 0, len(files))
|
||||
for _, a := range files {
|
||||
attachments = append(attachments, toAttachmentJSON(a))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"group": toProjectGroupJSON(*g), "projects": inside, "attachments": attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUploadGroupAttachment puts a document on the group itself.
|
||||
//
|
||||
// The same storage as everywhere else — content-addressed by hash, so the path
|
||||
// never contains anything the uploader chose. What differs is only the parent.
|
||||
func (s *Server) handleUploadGroupAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// Scoped by the lookup rather than by a separate check: a group that is not
|
||||
// yours is not found, which is the rule for everything in this file.
|
||||
if _, err := s.db.GetProjectGroup(r.Context(), groupID, user.ID); err != nil {
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxUploadBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
GroupID: groupID,
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
// handleDeleteProjectGroup removes the heading. The projects under it come back
|
||||
// out as ungrouped, which is why this is not behind a confirmation about losing
|
||||
// work: there is none to lose.
|
||||
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, "delete project group", err)
|
||||
s.storeError(w, r, "delete project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||||
@@ -145,7 +282,7 @@ func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder project groups", err)
|
||||
s.internal(w, r, "reorder project groups", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -269,3 +269,83 @@ func TestAColourMustBeOneFromThePalette(t *testing.T) {
|
||||
t.Errorf("name = %v", renamed["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// A group is somewhere you can go, not only a heading.
|
||||
func TestAGroupIsAPageWithItsProjectsDescriptionAndFiles(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{
|
||||
"name": "Sæsonstart", "group_id": groupID,
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab", "group_id": groupID})
|
||||
// And one outside it, which must not appear.
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Haven"})
|
||||
|
||||
// Two open, one closed — the count is what is left, not what has ever been.
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "skriv oplæg", "project_id": first["id"].(string),
|
||||
})
|
||||
_, done := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "book lokale", "project_id": first["id"].(string),
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+done["id"].(string)+"/complete", nil)
|
||||
|
||||
resp, page := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get group: status %d, body %v", resp.StatusCode, page)
|
||||
}
|
||||
projects, _ := page["projects"].([]any)
|
||||
if len(projects) != 2 {
|
||||
t.Fatalf("the page lists %d projects, want 2", len(projects))
|
||||
}
|
||||
for _, raw := range projects {
|
||||
p := raw.(map[string]any)
|
||||
if p["name"] == "Haven" {
|
||||
t.Error("a project outside the group is on its page")
|
||||
}
|
||||
if p["name"] == "Sæsonstart" && p["open_tasks"] != float64(1) {
|
||||
t.Errorf("open_tasks = %v, want 1 — the closed one must not count", p["open_tasks"])
|
||||
}
|
||||
}
|
||||
|
||||
// The description, which is the part a heading cannot carry.
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID, map[string]any{
|
||||
"description": "Alt det, der har en faktura for enden.",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("set description: status %d", resp.StatusCode)
|
||||
}
|
||||
_, again := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
||||
if g, _ := again["group"].(map[string]any); g["description"] != "Alt det, der har en faktura for enden." {
|
||||
t.Errorf("description = %v", g["description"])
|
||||
}
|
||||
// And setting only the description must not lose the name or colour.
|
||||
if g, _ := again["group"].(map[string]any); g["name"] != "Arbejde" {
|
||||
t.Errorf("the name went with the description: %v", g["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// A group belongs to one person and is never shared, so somebody else's group is
|
||||
// not found — including its files.
|
||||
func TestAGroupsPageAndFilesAreItsOwnersAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/project-groups/" + groupID},
|
||||
{"PATCH", "/api/v1/project-groups/" + groupID},
|
||||
{"POST", "/api/v1/project-groups/" + groupID + "/attachments"},
|
||||
} {
|
||||
resp, _ := other.do(t, c.method, c.path, map[string]any{"description": "min nu"})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s %s as somebody else: status %d, want 404", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type projectJSON struct {
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
Role string `json:"role"`
|
||||
MemberCount int `json:"member_count"`
|
||||
OpenCount int `json:"open_count"`
|
||||
Shared bool `json:"shared"`
|
||||
// GroupID is the sidebar group the asking user has filed it under, empty for
|
||||
// none. Always empty on a project shared with them: grouping is a column on
|
||||
@@ -34,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
|
||||
return projectJSON{
|
||||
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
||||
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
|
||||
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
|
||||
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount,
|
||||
Shared: p.MemberCount > 1, GroupID: p.GroupID,
|
||||
}
|
||||
}
|
||||
@@ -45,7 +46,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
||||
if err != nil {
|
||||
s.internal(w, "list projects", err)
|
||||
s.internal(w, r, "list projects", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectJSON, 0, len(projects))
|
||||
@@ -59,7 +60,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||
@@ -105,7 +106,7 @@ func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder projects", err)
|
||||
s.internal(w, r, "reorder projects", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -138,9 +139,21 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
p.ViewMode = *req.ViewMode
|
||||
}
|
||||
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
||||
s.internal(w, "create project", err)
|
||||
s.internal(w, r, "create project", err)
|
||||
return
|
||||
}
|
||||
// Filed under a group in the same call. `group_id` was declared on this
|
||||
// request type, documented on it, applied by the update handler — and silently
|
||||
// dropped here, so creating a project into a group answered 201 and put it
|
||||
// nowhere. A field that is accepted and thrown away is worse than one that is
|
||||
// refused.
|
||||
if req.GroupID != nil && *req.GroupID != "" {
|
||||
if err := s.db.SetProjectGroup(r.Context(), p.ID, *req.GroupID, user.ID); err != nil {
|
||||
s.storeError(w, r, "set project group", err)
|
||||
return
|
||||
}
|
||||
p.GroupID = *req.GroupID
|
||||
}
|
||||
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||
s.publishToOwner(r, "project.created", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||
@@ -174,7 +187,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
ViewMode: req.ViewMode, Archived: req.Archived,
|
||||
})
|
||||
if err != nil {
|
||||
s.storeError(w, "update project", err)
|
||||
s.storeError(w, r, "update project", err)
|
||||
return
|
||||
}
|
||||
// Grouping is written separately because it is scoped differently: the fields
|
||||
@@ -184,7 +197,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
if req.GroupID != nil {
|
||||
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, "set project group", err)
|
||||
s.storeError(w, r, "set project group", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -192,7 +205,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||
@@ -206,7 +219,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "delete project", err)
|
||||
s.storeError(w, r, "delete project", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "project.deleted", nil)
|
||||
@@ -230,7 +243,7 @@ type trashedProjectJSON struct {
|
||||
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list trashed projects", err)
|
||||
s.internal(w, r, "list trashed projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,7 +265,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
// deliberately invisible to it. Ownership is what governs restoring.
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, "project owner", err)
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if owner != userFrom(r.Context()).ID {
|
||||
@@ -260,7 +273,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
||||
s.storeError(w, "restore project", err)
|
||||
s.storeError(w, r, "restore project", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -306,7 +319,7 @@ type sectionJSON struct {
|
||||
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
||||
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
||||
if err != nil {
|
||||
s.internal(w, "list sections", err)
|
||||
s.internal(w, r, "list sections", err)
|
||||
return
|
||||
}
|
||||
out := make([]sectionJSON, 0, len(sections))
|
||||
@@ -336,7 +349,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
||||
sec.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
||||
s.internal(w, "create section", err)
|
||||
s.internal(w, r, "create section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
||||
@@ -350,7 +363,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
|
||||
sectionID := chi.URLParam(r, "sectionID")
|
||||
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
||||
if err != nil {
|
||||
s.storeError(w, "section project", err)
|
||||
s.storeError(w, r, "section project", err)
|
||||
return "", "", false
|
||||
}
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
||||
@@ -381,7 +394,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
||||
Name: req.Name, SortOrder: req.SortOrder,
|
||||
}); err != nil {
|
||||
s.storeError(w, "update section", err)
|
||||
s.storeError(w, r, "update section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.updated", nil)
|
||||
@@ -394,7 +407,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
||||
s.storeError(w, "delete section", err)
|
||||
s.storeError(w, r, "delete section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.deleted", nil)
|
||||
@@ -414,7 +427,7 @@ type memberJSON struct {
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||
if err != nil {
|
||||
s.internal(w, "list members", err)
|
||||
s.internal(w, r, "list members", err)
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
@@ -463,7 +476,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -475,7 +488,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||
s.internal(w, "add member", err)
|
||||
s.internal(w, r, "add member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||
@@ -485,7 +498,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create invite", err)
|
||||
s.internal(w, r, "create invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
@@ -502,13 +515,60 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
|
||||
}
|
||||
|
||||
type memberRoleRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleSetMemberRole changes an existing member between editor and viewer.
|
||||
//
|
||||
// Until now the only way to correct an invite sent with the wrong role was to
|
||||
// remove the person and invite them again — which also unassigns everything they
|
||||
// were responsible for, because removing a member clears their tasks' assignee.
|
||||
// Fixing a dropdown should not cost somebody their work.
|
||||
func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req memberRoleRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
role := store.Role(req.Role)
|
||||
if !role.Valid() || role == store.RoleOwner {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||
return
|
||||
}
|
||||
|
||||
// The owner has no project_members row at all — ownership lives on the project
|
||||
// — so this would report "not a member", which is true and completely
|
||||
// unhelpful. Say the real reason instead.
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"the owner's standing cannot be changed; ownership is transferred, not granted")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil {
|
||||
s.storeError(w, r, "set member role", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.role_changed",
|
||||
map[string]any{"user_id": userID, "role": string(role)})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, "project owner", err)
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
@@ -517,7 +577,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
|
||||
s.storeError(w, "remove member", err)
|
||||
s.storeError(w, r, "remove member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
||||
@@ -529,13 +589,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
|
||||
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
|
||||
// to learn.
|
||||
func (s *Server) storeError(w http.ResponseWriter, what string, err error) {
|
||||
func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
case errors.Is(err, store.ErrEmailInUse):
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
||||
default:
|
||||
s.internal(w, what, err)
|
||||
s.internal(w, r, what, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,3 +194,230 @@ func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A role sent by mistake used to be uncorrectable: the only way back was to remove
|
||||
// the person and invite them again, which unassigns every task they were
|
||||
// responsible for. Fixing a dropdown should not cost somebody their work.
|
||||
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
member := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
memberUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "viewer",
|
||||
})
|
||||
|
||||
// A viewer cannot write. That is the rule, not the bug.
|
||||
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
||||
map[string]any{"content": "prøv", "project_id": projectID}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("a viewer created a task: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||
map[string]any{"role": "editor"})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("promote to editor: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
|
||||
map[string]any{"content": "nu kan jeg", "project_id": projectID}); resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("an editor could not create a task: status %d", resp.StatusCode)
|
||||
}
|
||||
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
for _, raw := range members["members"].([]any) {
|
||||
if m := raw.(map[string]any); m["user_id"] == memberUser.ID && m["role"] != "editor" {
|
||||
t.Errorf("role = %v after the change, want editor", m["role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The owner is not a member row, and ownership is not a role you can hand out.
|
||||
func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
member := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
owner, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "editor",
|
||||
})
|
||||
|
||||
// Demoting the owner would leave a project nobody can administer.
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+owner.ID,
|
||||
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("changing the owner's role: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
// And no member may promote themselves to owner.
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||
map[string]any{"role": "owner"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("granting ownership: status %d, want 422", resp.StatusCode)
|
||||
}
|
||||
// Nor may an editor change anybody's role: this is the owner's to do.
|
||||
if resp, _ := member.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
|
||||
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an editor changed a role: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
// Somebody with no membership at all is not quietly given one.
|
||||
stranger := ts.newUser(t, "[email protected]", "Tredje")
|
||||
_ = stranger
|
||||
strangerUser, _ := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+strangerUser.ID,
|
||||
map[string]any{"role": "editor"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A project can have more than one section.
|
||||
//
|
||||
// Reported from use: sections "have no function, because you cannot create more
|
||||
// than one". Every test here made exactly one, which is the shape of test that
|
||||
// cannot see this — and so is every smoke test.
|
||||
func TestAProjectCanHaveSeveralSections(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
|
||||
id := project["id"].(string)
|
||||
|
||||
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
|
||||
for _, name := range names {
|
||||
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections",
|
||||
map[string]any{"name": name})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create section %q: status %d, body %v", name, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
|
||||
got, _ := listed["sections"].([]any)
|
||||
if len(got) != len(names) {
|
||||
t.Fatalf("the project has %d sections, want %d", len(got), len(names))
|
||||
}
|
||||
// In the order they were made, and each with its own place: a sort_order that
|
||||
// came out the same for all three would put them in an arbitrary order that
|
||||
// changes between reads.
|
||||
seen := map[float64]bool{}
|
||||
for i, raw := range got {
|
||||
s := raw.(map[string]any)
|
||||
if s["name"] != names[i] {
|
||||
t.Errorf("section %d is %v, want %q", i, s["name"], names[i])
|
||||
}
|
||||
order, _ := s["sort_order"].(float64)
|
||||
if seen[order] {
|
||||
t.Errorf("two sections share sort_order %v", order)
|
||||
}
|
||||
seen[order] = true
|
||||
}
|
||||
}
|
||||
|
||||
// A project can be created straight into a group.
|
||||
//
|
||||
// `group_id` was declared on the request type, documented on it, and applied by
|
||||
// the update handler — but the create handler ignored it, so a client that asked
|
||||
// for a project in a group got a 201 and a project in no group. A field that is
|
||||
// accepted and thrown away is worse than one that is refused: nothing reports it.
|
||||
func TestAProjectCanBeCreatedIntoAGroup(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
resp, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{
|
||||
"name": "Sæsonstart", "group_id": groupID,
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: status %d, body %v", resp.StatusCode, project)
|
||||
}
|
||||
if project["group_id"] != groupID {
|
||||
t.Errorf("the response says group_id = %v, want %q", project["group_id"], groupID)
|
||||
}
|
||||
|
||||
// And it is really filed there, not just claimed in the reply.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
var found map[string]any
|
||||
for _, raw := range listed["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == project["id"] {
|
||||
found = p
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the new project is not in the list")
|
||||
}
|
||||
if found["group_id"] != groupID {
|
||||
t.Errorf("the stored project has group_id = %v, want %q", found["group_id"], groupID)
|
||||
}
|
||||
}
|
||||
|
||||
// A section holds as many tasks as you put in it.
|
||||
//
|
||||
// Reported from use: "sections can only have one task". Driven through the same
|
||||
// endpoint the interface uses, with the same `after_id` the drop handler sends —
|
||||
// the second task goes after the first, which is the case that would fail if
|
||||
// positioning inside a section were wrong.
|
||||
func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Skovvænget"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Håndværker"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
var previous string
|
||||
for _, content := range []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"} {
|
||||
_, created := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": content, "project_id": projectID,
|
||||
})
|
||||
id := created["id"].(string)
|
||||
|
||||
resp, moved := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
|
||||
"project_id": projectID, "section_id": sectionID, "after_id": previous,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("move %q: status %d, body %v", content, resp.StatusCode, moved)
|
||||
}
|
||||
if moved["section_id"] != sectionID {
|
||||
t.Fatalf("%q did not land in the section: %v", content, moved["section_id"])
|
||||
}
|
||||
previous = id
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
|
||||
tasks, _ := listed["tasks"].([]any)
|
||||
inSection := []string{}
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["section_id"] == sectionID {
|
||||
inSection = append(inSection, task["content"].(string))
|
||||
}
|
||||
}
|
||||
if len(inSection) != 3 {
|
||||
t.Fatalf("the section holds %d tasks, want 3: %v", len(inSection), inSection)
|
||||
}
|
||||
// And in the order they were placed, since each went after the one before it.
|
||||
want := []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"}
|
||||
for i, content := range want {
|
||||
if inSection[i] != content {
|
||||
t.Errorf("position %d is %q, want %q — the whole list: %v", i, inSection[i], content, inSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/mail"
|
||||
"github.com/kristianwind/verdande/internal/mcp"
|
||||
@@ -39,11 +41,34 @@ type Server struct {
|
||||
mcp *mcp.Server
|
||||
updates *update.Checker
|
||||
|
||||
// syncing serialises reads of one mailbox against itself. Two runs at once —
|
||||
// somebody pressing "Fetch now" while the ten-minute sweep is in the middle of
|
||||
// the same mailbox — both start from the marker as it was, both fetch the same
|
||||
// messages, and both make tasks out of them. That is how forty copies of one
|
||||
// mail arrived in a single second.
|
||||
//
|
||||
// A map of mutexes rather than one lock: two people's mailboxes have nothing to
|
||||
// say to each other, and a single lock would make one slow host everybody's
|
||||
// problem. Never cleaned out, because the key is a mailbox id and there are as
|
||||
// many of those as there are mailboxes.
|
||||
syncing sync.Map
|
||||
|
||||
// Nil when the base URL is not something an authenticator will accept — a bare
|
||||
// hostname with no scheme, say. The endpoints answer 503 rather than the
|
||||
// process refusing to start: passkeys are one way in among three, and an
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
loginLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
// the ordinary case — and a six-digit code is only safe while guesses are
|
||||
// scarce, so the scarcity has to be counted against the thing being attacked.
|
||||
totpLimiter *limiter
|
||||
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
@@ -61,8 +86,17 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
}
|
||||
s.mcp = s.buildMCP()
|
||||
s.updates = update.New(Version, cfg.UpdateCheck)
|
||||
|
||||
@@ -108,6 +142,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
|
||||
// The calendar comes back on a URI of its own rather than sharing Gmail's.
|
||||
// One callback deciding from stored state which of the two flows had
|
||||
// returned would put the choice of *which token store to write* behind a
|
||||
// lookup — and when that goes wrong it goes wrong by overwriting a working
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
|
||||
@@ -118,6 +158,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// The beacon's receiving end, and the number it produces. Both without a
|
||||
// session, because the caller is a stranger's installation — that is the
|
||||
// whole feature. Rate limited like the login routes: it is the one endpoint
|
||||
// anybody on the internet can write to, and a row per request is a row per
|
||||
// request whether or not the request is honest.
|
||||
//
|
||||
// Refused with a 404 unless this instance has been told it is the collector,
|
||||
// so an ordinary installation is not a spam sink that answers 200 to
|
||||
// anything.
|
||||
r.Method(http.MethodPost, "/beacon",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeaconPing)))
|
||||
r.Get("/beacon/count", s.handleBeaconCount)
|
||||
|
||||
// Reachable without being signed in — these are how you become signed in.
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Get("/setup", s.handleSetupState)
|
||||
@@ -132,6 +185,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Method(http.MethodPost, "/password/reset",
|
||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
||||
|
||||
// Signing in with a passkey. Rate-limited like the password path: a
|
||||
// signature cannot be guessed, but the endpoint can still be used to
|
||||
// hammer the database.
|
||||
r.Method(http.MethodPost, "/passkey/login/begin",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
|
||||
r.Method(http.MethodPost, "/passkey/login/finish",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
|
||||
|
||||
// The second step of a login: a session that has passed the password
|
||||
// but not yet the code, and can reach nothing else.
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -144,13 +205,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/me", s.handleMe)
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Behind requireSession, like the API tokens and for the same
|
||||
// reason: a leaked token must not be able to end the sessions of
|
||||
@@ -159,6 +218,32 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
|
||||
// Turning the second factor on, and the codes that get you back
|
||||
// in. Sessions only, and setup asks for the password.
|
||||
//
|
||||
// These sat outside this group, which meant the door opened with
|
||||
// a token and closed only with a password: `disable` has always
|
||||
// asked for the password, `setup` asked for nothing. A stolen
|
||||
// token could therefore fetch the secret, load it into its own
|
||||
// authenticator, confirm it, and keep the only copy of the
|
||||
// recovery codes. The owner's next login would stop at a code
|
||||
// they do not have, and they could not turn it off without
|
||||
// logging in first. A theft becomes a tenancy — the same
|
||||
// sentence written next door about passkeys, and the same rule.
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Registering a key, and the list of them. Sessions only, for
|
||||
// the same reason: a leaked token must not be able to add its
|
||||
// own way in — that would turn a theft into a tenancy.
|
||||
r.Get("/passkeys", s.handleListPasskeys)
|
||||
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
|
||||
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
|
||||
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
|
||||
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -230,12 +315,63 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r.Get("/version", s.handleVersion)
|
||||
|
||||
// Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each
|
||||
// belongs to the person who connected it, so there is no admin gate
|
||||
// here and no shared registration: the credential is theirs.
|
||||
// Notes. Listing and searching are the same endpoint because they answer
|
||||
// the same question with different words; the backwards one — what has
|
||||
// been written about this task — hangs off the thing being asked about.
|
||||
r.Route("/notes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotes)
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
// A picture pasted or dropped into a note. The import could bring
|
||||
// files in and nothing else could, which made the most ordinary act
|
||||
// in a notes app — paste a screenshot — do nothing at all.
|
||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
r.Get("/", s.handleListMailboxes)
|
||||
r.Post("/", s.handleAddMailbox)
|
||||
r.Delete("/{mailboxID}", s.handleDeleteMailbox)
|
||||
r.Post("/{mailboxID}/sync", s.handleSyncMailbox)
|
||||
})
|
||||
|
||||
r.Route("/gmail", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetGmail)
|
||||
r.Put("/", s.handleSetGmail)
|
||||
r.Delete("/", s.handleDisconnectGmail)
|
||||
r.Post("/authorize", s.handleGmailAuthorize)
|
||||
r.Post("/sync", s.handleGmailSyncNow)
|
||||
|
||||
// The OAuth client itself is the instance's registration with
|
||||
// Google, not anybody's mailbox — so unlike everything above it, it
|
||||
// is administrators only, and behind a session as well: a leaked
|
||||
// token must not be able to read or replace this instance's
|
||||
// identity to Google.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession, s.requireAdmin)
|
||||
r.Get("/client", s.handleGetGmailClient)
|
||||
r.Put("/client", s.handleSetGmailClient)
|
||||
})
|
||||
})
|
||||
|
||||
// A calendar read from somewhere else, laid under verdande's own dated
|
||||
// tasks. Read-only, so there is no write here to gate: the events are a
|
||||
// copy of somebody else's calendar, and the only thing a person decides
|
||||
// is which of them to look at.
|
||||
r.Route("/calendar", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetCalendar)
|
||||
r.Put("/calendars", s.handleSetCalendars)
|
||||
r.Delete("/", s.handleDisconnectCalendar)
|
||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||
r.Post("/sync", s.handleCalendarSyncNow)
|
||||
r.Get("/events", s.handleCalendarEvents)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
@@ -244,6 +380,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
|
||||
@@ -264,6 +401,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/delegated", s.handleDelegated)
|
||||
r.Get("/people", s.handlePeople)
|
||||
r.Get("/search", s.handleSearch)
|
||||
|
||||
r.Route("/projects", func(r chi.Router) {
|
||||
@@ -292,6 +430,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateProject)
|
||||
r.Delete("/", s.handleDeleteProject)
|
||||
r.Post("/invites", s.handleInvite)
|
||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
})
|
||||
})
|
||||
@@ -303,6 +442,28 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// 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)
|
||||
// Who this instance reports to, and whether it does. An
|
||||
// administrator's decision, not a person's: there is one
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// 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)
|
||||
@@ -318,8 +479,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/", s.handleCreateProjectGroup)
|
||||
// Above /{groupID}, or chi reads "reorder" as an id.
|
||||
r.Post("/reorder", s.handleReorderProjectGroups)
|
||||
r.Get("/{groupID}", s.handleGetProjectGroup)
|
||||
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
|
||||
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
|
||||
r.Post("/{groupID}/attachments", s.handleUploadGroupAttachment)
|
||||
})
|
||||
|
||||
// The trash is outside the block above: a deleted project is invisible
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list sessions", err)
|
||||
s.internal(w, r, "list sessions", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := chi.URLParam(r, "sessionID")
|
||||
|
||||
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
||||
s.storeError(w, "delete session", err)
|
||||
s.storeError(w, r, "delete session", err)
|
||||
return
|
||||
}
|
||||
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
||||
|
||||
@@ -33,9 +33,14 @@ type taskJSON struct {
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// What the row can say without being opened. Sent with every task rather than
|
||||
// fetched when one is opened, because the point is that you see it in the list.
|
||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||
SubtaskDone int `json:"subtask_done,omitempty"`
|
||||
AttachmentCount int `json:"attachment_count,omitempty"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toTaskJSON(t store.Task) taskJSON {
|
||||
@@ -45,9 +50,11 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
|
||||
RepeatText: recurrence.Describe(t.RecurrenceRule),
|
||||
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
|
||||
SortOrder: t.SortOrder,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||
SubtaskCount: t.SubtaskCount, SubtaskDone: t.SubtaskDone,
|
||||
AttachmentCount: t.AttachmentCount,
|
||||
SortOrder: t.SortOrder,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||
// Never nil: a client iterating labels should not have to null-check first.
|
||||
Labels: t.Labels,
|
||||
}
|
||||
@@ -99,7 +106,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
||||
if err != nil {
|
||||
s.internal(w, "list tasks", err)
|
||||
s.internal(w, r, "list tasks", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||
@@ -116,7 +123,7 @@ func toTaskList(tasks []store.Task) []taskJSON {
|
||||
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
@@ -162,7 +169,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -226,7 +233,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
||||
s.internal(w, "create task", err)
|
||||
s.internal(w, r, "create task", err)
|
||||
return
|
||||
}
|
||||
// The store wrote the labels; the struct it was handed does not know that, and
|
||||
@@ -302,13 +309,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
||||
s.storeError(w, "update task", err)
|
||||
s.storeError(w, r, "update task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||
@@ -327,13 +334,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "complete task", err)
|
||||
s.storeError(w, r, "complete task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,13 +376,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
||||
s.storeError(w, "reopen task", err)
|
||||
s.storeError(w, r, "reopen task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
||||
@@ -394,11 +401,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||
s.storeError(w, "delete task", err)
|
||||
s.storeError(w, r, "delete task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
||||
@@ -427,7 +434,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
@@ -449,12 +456,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
||||
s.storeError(w, "move task", err)
|
||||
s.storeError(w, r, "move task", err)
|
||||
return
|
||||
}
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
||||
@@ -468,6 +475,10 @@ type quickAddRequest struct {
|
||||
// ProjectID is the view the user was looking at. A "#project" in the text
|
||||
// wins over it: what somebody typed beats where they happened to be standing.
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// SectionID is the section the box was opened in, for the field that sits at
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -502,7 +513,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -511,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolved after the project, because a section belongs to one project and the
|
||||
// same name can exist in several. A name that matches nothing is reported the
|
||||
// same way an unknown project is: the task is still created, in the project
|
||||
// without a section, and the caller is told which word did not land.
|
||||
sectionID, unknownSection := "", ""
|
||||
// The section the box belongs to, unless the text names one. Checked against
|
||||
// the project the task actually landed in, so a "#project" that moved it
|
||||
// elsewhere cannot drag a section id from another project along with it.
|
||||
if req.SectionID != "" {
|
||||
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
|
||||
sectionID = req.SectionID
|
||||
}
|
||||
}
|
||||
if parsed.Section != "" {
|
||||
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
|
||||
sectionID = id
|
||||
} else {
|
||||
unknownSection = parsed.Section
|
||||
}
|
||||
}
|
||||
|
||||
t := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
@@ -523,20 +556,21 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
||||
s.internal(w, "create task", err)
|
||||
s.internal(w, r, "create task", err)
|
||||
return
|
||||
}
|
||||
t.Labels = parsed.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
if unknownProject != "" || unknownSection != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
|
||||
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
|
||||
t.Errorf("unread = %v after marking everything read", after["unread"])
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add can put a task straight into a section.
|
||||
//
|
||||
// A section belongs to one project, so the name is resolved after the project is
|
||||
// decided — and two projects may both have a "Produktion", which are different
|
||||
// sections. A name that matches nothing is reported the way an unknown project is:
|
||||
// the task is still created, and the caller is told which word did not land.
|
||||
func TestQuickAddPutsATaskInASection(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Kunder/Ordrer"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// The section name has a slash in it, which is also the sigil. The bare form is
|
||||
// greedy, so the whole name comes across.
|
||||
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
|
||||
}
|
||||
if task["content"] != "Karpenhøj kaffe bestilling" {
|
||||
t.Errorf("content = %v — the sigils should be gone", task["content"])
|
||||
}
|
||||
|
||||
// Case-insensitive, like the project sigil.
|
||||
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
|
||||
if lower["section_id"] != sectionID {
|
||||
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
|
||||
}
|
||||
|
||||
// A name that matches nothing still creates the task, and says so.
|
||||
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
|
||||
if missing["section_id"] != nil && missing["section_id"] != "" {
|
||||
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
|
||||
}
|
||||
if missing["unknown_section"] != "Findesikke" {
|
||||
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
|
||||
}
|
||||
if missing["content"] != "noget andet" {
|
||||
t.Errorf("content = %v", missing["content"])
|
||||
}
|
||||
|
||||
// A section in another project is a different section, and must not be found.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
|
||||
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
|
||||
map[string]any{"name": "Egen"})
|
||||
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "forkert sted #Risteriet /Egen"})
|
||||
if crossed["unknown_section"] != "Egen" {
|
||||
t.Errorf("a section from another project was found: %v", crossed["section_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// The field at the foot of a section passes the section it sits in, and a
|
||||
// "#project" in the same line can move the task out from under it.
|
||||
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
|
||||
hereID := here["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
|
||||
map[string]any{"name": "Produktion"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// Standing in the section is enough; no sigil needed.
|
||||
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if plain["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
|
||||
}
|
||||
|
||||
// A "#project" that moves the task must not drag the section id with it: that
|
||||
// section belongs to the project being left behind.
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if moved["section_id"] != nil && moved["section_id"] != "" {
|
||||
t.Errorf("the section came along to another project: %v", moved["section_id"])
|
||||
}
|
||||
if moved["content"] != "male gavlen" {
|
||||
t.Errorf("content = %v", moved["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ type createdTokenJSON struct {
|
||||
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list api tokens", err)
|
||||
s.internal(w, r, "list api tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
|
||||
if err != nil {
|
||||
s.internal(w, "create api token", err)
|
||||
s.internal(w, r, "create api token", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete api token", err)
|
||||
s.storeError(w, r, "delete api token", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -20,7 +20,24 @@ type totpSetupResponse struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
// The password, the same as disable has always asked for.
|
||||
//
|
||||
// Symmetry is the point. Turning the second factor on is at least as
|
||||
// consequential as turning it off — it decides which device gets to say yes
|
||||
// from now on — and a door that opens more easily than it closes is not a
|
||||
// door. Together with the move behind requireSession, this is what stops a
|
||||
// stolen credential from enrolling its own authenticator and keeping the only
|
||||
// copy of the recovery codes.
|
||||
var req totpDisableRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil {
|
||||
writeFieldErrors(w, map[string]string{"password": "not correct"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"two-factor authentication is already switched on")
|
||||
@@ -29,12 +46,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
|
||||
if err != nil {
|
||||
s.internal(w, "generate totp secret", err)
|
||||
s.internal(w, r, "generate totp secret", err)
|
||||
return
|
||||
}
|
||||
// Stored but not enabled: confirm decides that.
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
|
||||
s.internal(w, "store totp secret", err)
|
||||
s.internal(w, r, "store totp secret", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
||||
@@ -63,15 +80,15 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
s.internal(w, r, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
s.internal(w, r, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
||||
s.internal(w, "enable totp", err)
|
||||
s.internal(w, r, "enable totp", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,7 +115,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
|
||||
s.internal(w, "disable totp", err)
|
||||
s.internal(w, r, "disable totp", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
||||
@@ -127,11 +144,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
s.internal(w, r, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
s.internal(w, r, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||
@@ -140,7 +157,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count recovery codes", err)
|
||||
s.internal(w, r, "count recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
|
||||
|
||||
@@ -30,11 +30,13 @@ type adminUserJSON struct {
|
||||
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. 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"`
|
||||
// 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 {
|
||||
@@ -54,12 +56,12 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
users, err := s.db.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "list users", err)
|
||||
s.internal(w, r, "list users", err)
|
||||
return
|
||||
}
|
||||
invites, err := s.db.ListPendingInvites(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "list pending invites", err)
|
||||
s.internal(w, r, "list pending invites", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
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".
|
||||
@@ -128,7 +131,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
|
||||
inviter.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create instance invite", err)
|
||||
s.internal(w, r, "create instance invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
@@ -170,7 +173,7 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
|
||||
s.storeError(w, "set user admin", err)
|
||||
s.storeError(w, r, "set user admin", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
|
||||
@@ -197,7 +200,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
|
||||
s.storeError(w, "delete user", err)
|
||||
s.storeError(w, r, "delete user", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("user deleted", "by", me.ID, "user", userID)
|
||||
@@ -206,7 +209,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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, "delete invite", err)
|
||||
s.storeError(w, r, "delete invite", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -218,7 +221,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
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, "get user", err)
|
||||
s.storeError(w, r, "get user", err)
|
||||
return err
|
||||
}
|
||||
if !target.IsAdmin {
|
||||
@@ -226,7 +229,7 @@ func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userI
|
||||
}
|
||||
admins, err := s.db.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "count admins", err)
|
||||
s.internal(w, r, "count admins", err)
|
||||
return err
|
||||
}
|
||||
if admins <= 1 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -125,8 +126,8 @@ func TestASecondAdministratorCanBeDemoted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an account takes more than its own projects, and the list says so
|
||||
// before anybody presses the button.
|
||||
// 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)
|
||||
@@ -143,9 +144,10 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||
"content": "deres egen", "project_id": theirs["id"].(string),
|
||||
})
|
||||
|
||||
// And a task they wrote in a project somebody else owns. tasks.created_by
|
||||
// cascades, so this one goes too — and a count that missed it would understate
|
||||
// the damage in exactly the case that matters.
|
||||
// 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{
|
||||
@@ -168,18 +170,52 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||
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(2) {
|
||||
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
|
||||
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 really does take it.
|
||||
// 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)
|
||||
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
|
||||
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,3 +313,70 @@ func indexAfter(s, needle string) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// A 500 has to leave something behind that a restart cannot take.
|
||||
//
|
||||
// The panel's watcher already reports that one happened. What it cannot report is
|
||||
// what it was: that lives in the container's log, and a Rune replaces the
|
||||
// container on every restart, so the explanation is usually gone before anybody
|
||||
// looks.
|
||||
func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Nothing has gone wrong yet.
|
||||
_, empty := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
if rows, _ := empty["errors"].([]any); len(rows) != 0 {
|
||||
t.Fatalf("a fresh instance has errors already: %v", rows)
|
||||
}
|
||||
|
||||
// Break something the handler cannot survive: the table it reads is gone, so
|
||||
// the query fails and the handler answers 500. Dropping it is a blunt way to
|
||||
// provoke that, and a faithful one — it is the same code path a disk error or
|
||||
// a corrupt page would take.
|
||||
if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := body["errors"].([]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want one recorded error, got %v", rows)
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["what"] != "list project groups" {
|
||||
t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"])
|
||||
}
|
||||
if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" {
|
||||
t.Errorf("path/method = %v %v", row["method"], row["path"])
|
||||
}
|
||||
if row["status"] != float64(500) {
|
||||
t.Errorf("status = %v", row["status"])
|
||||
}
|
||||
if row["message"] == "" {
|
||||
t.Error("no message: the row says something broke and not what")
|
||||
}
|
||||
if row["user_name"] != "Kristian" {
|
||||
t.Errorf("user_name = %v — a fault only one account hits is a different problem",
|
||||
row["user_name"])
|
||||
}
|
||||
if row["request_id"] == "" {
|
||||
t.Error("no request id, so the row cannot be tied back to the log line")
|
||||
}
|
||||
}
|
||||
|
||||
// The list is an administrator's: it carries paths, accounts and the server's own
|
||||
// error strings.
|
||||
func TestTheErrorLogIsAdminsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "today", err)
|
||||
s.internal(w, r, "today", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "upcoming", err)
|
||||
s.internal(w, r, "upcoming", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,6 +92,31 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
||||
}
|
||||
|
||||
// handlePeople lists everybody the caller shares a project with, so an interface
|
||||
// can put a name and a face on an assignee id.
|
||||
//
|
||||
// One request per session rather than a lookup per row, and not the instance's
|
||||
// user list — that is the administrator's page. A task list has no business
|
||||
// enumerating everybody with an account here.
|
||||
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
|
||||
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list people", err)
|
||||
return
|
||||
}
|
||||
out := make([]personJSON, 0, len(people))
|
||||
for _, p := range people {
|
||||
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"people": out})
|
||||
}
|
||||
|
||||
type personJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
}
|
||||
|
||||
type delegatedPerson struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -117,7 +142,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "delegated", err)
|
||||
s.internal(w, r, "delegated", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
people, err := s.db.PeopleByIDs(r.Context(), ids)
|
||||
if err != nil {
|
||||
s.internal(w, "delegated people", err)
|
||||
s.internal(w, r, "delegated people", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "search tasks", err)
|
||||
s.internal(w, r, "search tasks", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -192,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// second FTS index for that would be more machinery than the problem needs.
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||
if err != nil {
|
||||
s.internal(w, "search projects", err)
|
||||
s.internal(w, r, "search projects", err)
|
||||
return
|
||||
}
|
||||
matched := []projectJSON{}
|
||||
@@ -214,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
||||
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
||||
if err != nil {
|
||||
s.internal(w, "list activity", err)
|
||||
s.internal(w, r, "list activity", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -153,3 +153,50 @@ func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
|
||||
t.Errorf("a task in a project they cannot see: %v", people)
|
||||
}
|
||||
}
|
||||
|
||||
// /people is what puts a name on an assignee id. It must be the people you share
|
||||
// work with — not the instance's user list, which is the administrator's page.
|
||||
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
colleague := ts.newUser(t, "[email protected]", "Anders")
|
||||
ts.newUser(t, "[email protected]", "Fremmed")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "editor",
|
||||
})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/people", nil)
|
||||
names := map[string]bool{}
|
||||
for _, raw := range body["people"].([]any) {
|
||||
p := raw.(map[string]any)
|
||||
names[p["name"].(string)] = true
|
||||
if p["avatar_color"] == "" {
|
||||
t.Errorf("no avatar colour for %v — the row cannot draw a face", p["name"])
|
||||
}
|
||||
}
|
||||
|
||||
if !names["Kristian"] {
|
||||
t.Error("yourself is missing, so a client cannot tell 'me' from 'somebody else' against one list")
|
||||
}
|
||||
if !names["Anders"] {
|
||||
t.Error("somebody sharing a project is missing")
|
||||
}
|
||||
if names["Fremmed"] {
|
||||
t.Error("an account with no project in common is listed — this is not the user directory")
|
||||
}
|
||||
|
||||
// And it is symmetric: the person invited sees the person who invited them.
|
||||
_, theirs := colleague.do(t, "GET", "/api/v1/people", nil)
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range theirs["people"].([]any) {
|
||||
seen[raw.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !seen["Kristian"] || !seen["Anders"] {
|
||||
t.Errorf("the member cannot see who they share with: %v", seen)
|
||||
}
|
||||
if seen["Fremmed"] {
|
||||
t.Errorf("a stranger is listed: %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package i18n exists only to hold this test.
|
||||
//
|
||||
// The interface's strings live in the frontend, where they are read; nothing in Go
|
||||
// needs them. What Go can do is fail the build when the two dictionaries drift,
|
||||
// which is the failure mode a translation always has and which nothing else here
|
||||
// would notice: a key present in Danish and missing from English renders as Danish
|
||||
// inside an English screen, and a key present only in English is a string nobody
|
||||
// will ever see.
|
||||
//
|
||||
// A scanner rather than a parser, the same approach `colors_test.go` and
|
||||
// `openapi_test.go` take: it needs the keys out of two lists this repository owns,
|
||||
// and that is worth more than a dependency in a project whose whole shape is "one
|
||||
// static binary".
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheTwoDictionariesHaveTheSameKeys(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
|
||||
da := readKeys(t, filepath.Join(dir, "da.js"))
|
||||
en := readKeys(t, filepath.Join(dir, "en.js"))
|
||||
|
||||
if len(da) == 0 {
|
||||
t.Fatal("no keys found in da.js — the scanner is looking at the wrong shape")
|
||||
}
|
||||
|
||||
var missing, extra []string
|
||||
for key := range da {
|
||||
if !en[key] {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
for key := range en {
|
||||
if !da[key] {
|
||||
extra = append(extra, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("%d keys are in da.js and not in en.js — they render as Danish inside an "+
|
||||
"English screen:\n %s", len(missing), strings.Join(missing, "\n "))
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
t.Errorf("%d keys are in en.js and not in da.js — nothing reads them, because Danish "+
|
||||
"is the source:\n %s", len(extra), strings.Join(extra, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// The placeholders have to match too. `t()` fills `{name}` from what the caller
|
||||
// passes, so a translation that spells one differently leaves `{navn}` on the
|
||||
// screen — which is worse than an untranslated sentence, because it looks like a
|
||||
// bug in the app rather than a gap in the dictionary.
|
||||
func TestTheTwoDictionariesUseTheSamePlaceholders(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
|
||||
da := readEntries(t, filepath.Join(dir, "da.js"))
|
||||
en := readEntries(t, filepath.Join(dir, "en.js"))
|
||||
|
||||
for key, danish := range da {
|
||||
english, ok := en[key]
|
||||
if !ok {
|
||||
continue // reported by the test above
|
||||
}
|
||||
want := placeholders(danish)
|
||||
got := placeholders(english)
|
||||
if strings.Join(want, ",") != strings.Join(got, ",") {
|
||||
t.Errorf("%s: Danish fills %v, English fills %v", key, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// `'some.key': 'the string',` — single-quoted keys and values, which is what
|
||||
// prettier writes for these files.
|
||||
reEntry = regexp.MustCompile(`(?m)^\s*'([a-zA-Z0-9._]+)':\s*(.*?),?\s*$`)
|
||||
rePlaceholder = regexp.MustCompile(`\{(\w+)\}`)
|
||||
)
|
||||
|
||||
func readKeys(t *testing.T, path string) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
for key := range readEntries(t, path) {
|
||||
keys[key] = true
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func readEntries(t *testing.T, path string) map[string]string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, m := range reEntry.FindAllStringSubmatch(string(body), -1) {
|
||||
out[m[1]] = m[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func placeholders(s string) []string {
|
||||
var found []string
|
||||
for _, m := range rePlaceholder.FindAllStringSubmatch(s, -1) {
|
||||
found = append(found, m[1])
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Danish prose left in a component is the failure the dictionaries cannot see.
|
||||
//
|
||||
// `i18n_test.go` checks that the two dictionaries agree. It cannot check the thing
|
||||
// that actually went wrong: a string that was never put in either, because nobody
|
||||
// noticed it. Three survived the sweep — "Ingen nyere version fundet.", "Ikke mere
|
||||
// i dag." and a token hint split across two lines — and they survived it for the
|
||||
// same reason. The sweep grepped for æøå, and none of them contain one.
|
||||
//
|
||||
// So this reads the templates instead and looks for the words that give Danish
|
||||
// away whatever letters it happens to use. A word list rather than a language
|
||||
// detector: this repository ships one static binary, and a list of thirty common
|
||||
// words catches the sentences a person would actually write.
|
||||
// The same words, inside the code rather than the markup.
|
||||
//
|
||||
// `confirm()`, `alert()` and `app.toast()` take a string argument, and a string
|
||||
// argument lives in the script block where the scanner above never looks. Six of
|
||||
// them survived the whole i18n sweep that way — every destructive confirmation in
|
||||
// the app, which is the worst possible set to leave in one language, because it is
|
||||
// the text somebody reads in the half-second before they agree to lose something.
|
||||
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
||||
// Every string literal in the argument list of one of the three, not just one
|
||||
// sitting flush against the paren. A toast written as a ternary over two lines
|
||||
// hid Danish from the earlier version of this guard for a whole release.
|
||||
// Deliberately still narrow: a general scan of every literal in a script block
|
||||
// would drown in class names, keys and query fragments.
|
||||
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
|
||||
|
||||
var found []string
|
||||
root := filepath.Join("..", "..", "web", "src")
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(path, ".svelte") && !strings.HasSuffix(path, ".js") {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(path, "locales") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
src := string(body)
|
||||
for _, loc := range call.FindAllStringIndex(src, -1) {
|
||||
for _, lit := range literalsInCall(src[loc[1]:]) {
|
||||
if danish.MatchString(lit) {
|
||||
found = append(found, rel+": "+lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
t.Errorf("%d dialog or toast string(s) are written in Danish inside the code. "+
|
||||
"They are what somebody reads before agreeing to lose something, and an "+
|
||||
"English interface shows them in Danish:\n %s",
|
||||
len(found), strings.Join(found, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// literalsInCall returns the string literals in one argument list, given the text
|
||||
// just past its opening paren. It stops at the matching close paren so the next
|
||||
// statement's strings are somebody else's problem.
|
||||
func literalsInCall(src string) []string {
|
||||
var out []string
|
||||
depth := 1
|
||||
for i := 0; i < len(src); i++ {
|
||||
switch c := src[i]; c {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth--; depth == 0 {
|
||||
return out
|
||||
}
|
||||
case '\'', '"', '`':
|
||||
end := strings.IndexByte(src[i+1:], c)
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
|
||||
out = append(out, lit)
|
||||
}
|
||||
i += end + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||
// Text nodes of three characters or more, and the three attributes a person
|
||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||
// and is not what this is looking for.
|
||||
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
|
||||
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
|
||||
// Words that are Danish and are not also English. "is", "for" and "to" would
|
||||
// match English prose and are deliberately absent.
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
|
||||
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
styles := regexp.MustCompile(`(?s)<style>.*`)
|
||||
|
||||
var found []string
|
||||
root := filepath.Join("..", "..", "web", "src")
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".svelte") {
|
||||
return err
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
markup := string(body)
|
||||
if at := strings.Index(markup, "</script>"); at > 0 {
|
||||
markup = markup[at:]
|
||||
}
|
||||
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
|
||||
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
|
||||
attrs.FindAllStringSubmatch(markup, -1)...) {
|
||||
phrase := strings.TrimSpace(m[1])
|
||||
if danish.MatchString(phrase) {
|
||||
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
|
||||
"t(). An English interface shows them in Danish, and neither dictionary knows "+
|
||||
"they exist:\n %s", len(found), strings.Join(found, "\n "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// Package imap reads a mailbox over IMAP and hands back the messages that should
|
||||
// become tasks.
|
||||
//
|
||||
// This exists next to internal/gmail rather than inside it because the two agree
|
||||
// on almost nothing. Gmail is an HTTP API with OAuth, labels and stars; IMAP is a
|
||||
// connection with a password, folders and flags. What they do share is the shape
|
||||
// of the answer — a handful of messages, each with a sender, a subject and enough
|
||||
// of a body to read — and that shape is Message here, deliberately identical to
|
||||
// what the Gmail client returns, so the caller can be told where to look and not
|
||||
// how to look.
|
||||
//
|
||||
// iCloud, Fastmail and any ordinary host speak this. Office 365 no longer accepts
|
||||
// a password here at all — it wants OAuth of its own — so it is not a host you can
|
||||
// reach with this package, and pretending otherwise would only fail later and
|
||||
// less clearly.
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/emersion/go-message"
|
||||
)
|
||||
|
||||
// Message is one mail, reduced to what a task needs.
|
||||
type Message struct {
|
||||
UID uint32
|
||||
From string
|
||||
Subject string
|
||||
Snippet string
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// Account is one mailbox to read.
|
||||
type Account struct {
|
||||
Host string // "imap.mail.me.com:993"
|
||||
Username string
|
||||
Password string // an app-specific password on every host worth naming
|
||||
Folder string // "INBOX" when empty
|
||||
|
||||
// RootCAs adds a certificate authority to trust, on top of the system's. It
|
||||
// exists so a test can put a real IMAP server behind a self-signed certificate
|
||||
// and still be refused by everything else — the field can only widen trust,
|
||||
// never switch verification off, which is the difference between a seam and a
|
||||
// hole. Nil in production, where the system's roots are the answer.
|
||||
RootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
// Dial opens a TLS connection and logs in. The caller closes it.
|
||||
//
|
||||
// TLS is not optional and there is no flag to make it so: this carries a password
|
||||
// that unlocks a person's whole mailbox, and a switch for turning that protection
|
||||
// off is a switch that will one day be found already flipped.
|
||||
func Dial(a Account) (*Client, error) {
|
||||
host, _, err := net.SplitHostPort(a.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host must include a port, like imap.mail.me.com:993: %w", err)
|
||||
}
|
||||
|
||||
c, err := imapclient.DialTLS(a.Host, &imapclient.Options{
|
||||
TLSConfig: &tls.Config{
|
||||
ServerName: host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: a.RootCAs,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to %s: %w", a.Host, err)
|
||||
}
|
||||
|
||||
if err := c.Login(a.Username, a.Password).Wait(); err != nil {
|
||||
c.Close()
|
||||
// Deliberately not repeating the server's own words here. A bad password is
|
||||
// the overwhelmingly likely cause, and hosts answer it with everything from
|
||||
// "AUTHENTICATIONFAILED" to a sentence about web login being required.
|
||||
return nil, fmt.Errorf("%s refused the login for %s: %w", a.Host, a.Username, err)
|
||||
}
|
||||
|
||||
folder := a.Folder
|
||||
if folder == "" {
|
||||
folder = "INBOX"
|
||||
}
|
||||
return &Client{c: c, folder: folder}, nil
|
||||
}
|
||||
|
||||
// Client is a logged-in connection to one mailbox.
|
||||
type Client struct {
|
||||
c *imapclient.Client
|
||||
folder string
|
||||
}
|
||||
|
||||
// Close ends the session politely, then hangs up regardless.
|
||||
func (c *Client) Close() error {
|
||||
if err := c.c.Logout().Wait(); err != nil {
|
||||
return c.c.Close()
|
||||
}
|
||||
return c.c.Close()
|
||||
}
|
||||
|
||||
// Since returns the flagged messages in the folder newer than uid.
|
||||
//
|
||||
// Flagged, not unread: unread is a state the person is still using, and a sync
|
||||
// that consumed it would be taking something away. Flagging a mail is a deliberate
|
||||
// act with no other meaning, which is exactly what is wanted — the same reason the
|
||||
// Gmail side reads stars.
|
||||
// The second return is the highest uid the search considered. A caller that got
|
||||
// through every message can move its marker to that, and then it does not matter
|
||||
// whether the server put a uid in each fetch reply — which some do not, and which
|
||||
// is how the same mail became a task again on every run.
|
||||
func (c *Client) Since(uid uint32, limit int) ([]Message, uint32, error) {
|
||||
if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||
return nil, 0, fmt.Errorf("open %s: %w", c.folder, err)
|
||||
}
|
||||
|
||||
// From uid+1 to the end. A mailbox with nothing new answers with no uids
|
||||
// rather than an error, and that is not a failure worth reporting upwards.
|
||||
set := imap.UIDSetNum()
|
||||
set.AddRange(imap.UID(uid+1), 0)
|
||||
|
||||
criteria := &imap.SearchCriteria{
|
||||
UID: []imap.UIDSet{set},
|
||||
Flag: []imap.Flag{imap.FlagFlagged},
|
||||
}
|
||||
found, err := c.c.UIDSearch(criteria, nil).Wait()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search %s: %w", c.folder, err)
|
||||
}
|
||||
|
||||
uids := found.AllUIDs()
|
||||
|
||||
// Anything at or below the marker is dropped here rather than trusted to the
|
||||
// range in the query. A server that reads `1272:*` as `0:1272` — and one does,
|
||||
// which is how the same mail became a task every ten minutes with the marker
|
||||
// standing still at 1271 — hands back the whole mailbox. The marker is ours;
|
||||
// enforcing it here costs one pass over a list of at most a few hundred
|
||||
// numbers and does not depend on anybody else's reading of the spec.
|
||||
kept := make([]imap.UID, 0, len(uids))
|
||||
for _, u := range uids {
|
||||
if uint32(u) > uid {
|
||||
kept = append(kept, u)
|
||||
}
|
||||
}
|
||||
uids = kept
|
||||
|
||||
if len(uids) == 0 {
|
||||
return nil, uid, nil
|
||||
}
|
||||
if limit > 0 && len(uids) > limit {
|
||||
// The newest ones. A mailbox flagged over years would otherwise make the
|
||||
// first sync the largest one, which is the run most likely to be watched.
|
||||
uids = uids[len(uids)-limit:]
|
||||
}
|
||||
|
||||
want := imap.UIDSetNum(uids...)
|
||||
fetch := &imap.FetchOptions{
|
||||
Envelope: true,
|
||||
UID: true,
|
||||
// The whole message, headers included, so the MIME structure can be walked.
|
||||
// TEXT alone gives the body as bytes — boundaries and part headers and all —
|
||||
// which is what put "--=_be71cb39… Content-Transfer-Encoding: 8bit" at the
|
||||
// front of every task made from a multipart mail.
|
||||
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
|
||||
}
|
||||
var highest uint32
|
||||
for _, u := range uids {
|
||||
if uint32(u) > highest {
|
||||
highest = uint32(u)
|
||||
}
|
||||
}
|
||||
|
||||
msgs, err := c.c.Fetch(want, fetch).Collect()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("fetch from %s: %w", c.folder, err)
|
||||
}
|
||||
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for i, m := range msgs {
|
||||
if m.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// The uid comes from the search when the fetch reply does not carry one.
|
||||
// A missing uid is not cosmetic: it is the marker the next run starts from,
|
||||
// and a marker stuck at zero makes every message arrive again on every run.
|
||||
// That is what happened against a real server while the tests stayed green,
|
||||
// because the server they run against always answers with it.
|
||||
msgUID := uint32(m.UID)
|
||||
if msgUID == 0 && i < len(uids) {
|
||||
msgUID = uint32(uids[i])
|
||||
}
|
||||
|
||||
out = append(out, Message{
|
||||
UID: msgUID,
|
||||
From: address(m.Envelope.From),
|
||||
Subject: strings.TrimSpace(m.Envelope.Subject),
|
||||
Snippet: snippet(m),
|
||||
Date: m.Envelope.Date,
|
||||
})
|
||||
}
|
||||
return out, highest, nil
|
||||
}
|
||||
|
||||
// address renders the first sender as "Name <addr>", or just the address.
|
||||
func address(list []imap.Address) string {
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
a := list[0]
|
||||
addr := a.Addr()
|
||||
if a.Name == "" {
|
||||
return addr
|
||||
}
|
||||
return a.Name + " <" + addr + ">"
|
||||
}
|
||||
|
||||
// snippet is the first readable text in the mail.
|
||||
//
|
||||
// It walks the MIME structure rather than taking the body as bytes. text/plain is
|
||||
// preferred and HTML is the fallback with its tags stripped — crudely, because
|
||||
// this is the line under a task's title and not a rendering engine. The mail
|
||||
// itself stays where it is.
|
||||
const snippetLimit = 400
|
||||
|
||||
func snippet(m *imapclient.FetchMessageBuffer) string {
|
||||
for _, body := range m.BodySection {
|
||||
if text := readable(body.Bytes); text != "" {
|
||||
return cut(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readable(raw []byte) string {
|
||||
entity, err := message.Read(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
// Not something this parser recognises. The raw bytes read badly but read,
|
||||
// and a mail is not worth losing over its own headers.
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
return strings.TrimSpace(fromEntity(entity, 0))
|
||||
}
|
||||
|
||||
// fromEntity descends into a multipart mail. The depth guard is for the mail that
|
||||
// nests parts inside parts inside parts — rare, and cheaper to bound than to trust.
|
||||
func fromEntity(entity *message.Entity, depth int) string {
|
||||
if depth > 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if parts := entity.MultipartReader(); parts != nil {
|
||||
var html string
|
||||
for {
|
||||
part, err := parts.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if text := fromEntity(part, depth+1); text != "" {
|
||||
kind, _, _ := part.Header.ContentType()
|
||||
if kind != "text/html" {
|
||||
return text
|
||||
}
|
||||
if html == "" {
|
||||
html = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(entity.Body)
|
||||
kind, _, _ := entity.Header.ContentType()
|
||||
if kind == "text/html" {
|
||||
return stripTags(string(body))
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// stripTags removes markup without pretending to understand it. Enough for one
|
||||
// line of preview, and honest about being no more than that.
|
||||
func stripTags(html string) string {
|
||||
var b strings.Builder
|
||||
depth := 0
|
||||
for _, r := range html {
|
||||
switch {
|
||||
case r == '<':
|
||||
depth++
|
||||
case r == '>':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case depth == 0:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func cut(text string) string {
|
||||
runes := []rune(text)
|
||||
if len(runes) > snippetLimit {
|
||||
return string(runes[:snippetLimit]) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Unflag takes the flag off the messages that have become tasks.
|
||||
//
|
||||
// This is what stops the same mail arriving twice, and it replaces a marker that
|
||||
// could not be made to hold. The marker was a uid: remember the highest one seen,
|
||||
// start above it next time. That is correct only if uids are what the server says
|
||||
// they are, and it broke in every way such a scheme can — a fetch reply without a
|
||||
// uid, a UIDVALIDITY that changed under a rebuilt mailbox, two mailbox rows
|
||||
// pointing at the same folder and each keeping its own marker. Every one of those
|
||||
// produced the same symptom: the same mail became a task again, and again.
|
||||
//
|
||||
// Unflagging moves the record out of our database and into the mailbox, where the
|
||||
// question is asked. "Flagged" means "turn this into a task"; once it is a task,
|
||||
// the flag has done its job and is not true any more. Nothing has to be
|
||||
// remembered, so there is nothing to get out of step — a second sync, a second
|
||||
// mailbox row, or a database restored from a backup all find the same answer,
|
||||
// because the answer is in the mailbox.
|
||||
//
|
||||
// It is destructive in the small way that matters: it changes something in
|
||||
// somebody's mail. That is the trade, and it is the right way round — a flag is a
|
||||
// note to self that this needs doing, and it has been done.
|
||||
//
|
||||
// The folder is re-selected writable. Since() opens it read-only on purpose, and
|
||||
// a search should not need write access just because a later step might.
|
||||
func (c *Client) Unflag(uids []uint32) error {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := c.c.Select(c.folder, nil).Wait(); err != nil {
|
||||
return fmt.Errorf("open %s for writing: %w", c.folder, err)
|
||||
}
|
||||
|
||||
set := imap.UIDSetNum()
|
||||
for _, u := range uids {
|
||||
if u == 0 {
|
||||
// No uid means nothing to address. The message keeps its flag and will
|
||||
// be seen again — which is the failure this whole function exists to
|
||||
// prevent, so it is worth saying rather than skipping quietly.
|
||||
continue
|
||||
}
|
||||
set.AddNum(imap.UID(u))
|
||||
}
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Silent: the server has nothing to tell us that we would act on, and the
|
||||
// untagged replies would only have to be collected and thrown away.
|
||||
cmd := c.c.Store(set, &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsDel,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagFlagged},
|
||||
}, nil)
|
||||
if err := cmd.Close(); err != nil {
|
||||
return fmt.Errorf("unflag in %s: %w", c.folder, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
)
|
||||
|
||||
// A host without a port is the mistake everyone makes once, and the error a TLS
|
||||
// dial gives for it says nothing useful about which of the two fields is wrong.
|
||||
func TestAHostWithoutAPortSaysSo(t *testing.T) {
|
||||
_, err := Dial(Account{Host: "imap.mail.me.com", Username: "kw", Password: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("a host with no port was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "port") {
|
||||
t.Errorf("the error does not mention the port: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASenderIsRenderedForReading(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
in []imap.Address
|
||||
want string
|
||||
}{
|
||||
{"nobody", nil, ""},
|
||||
{"bare address", []imap.Address{{Mailbox: "kw", Host: "nolimit.dk"}}, "[email protected]"},
|
||||
{"with a name", []imap.Address{{Name: "Kristian Wind", Mailbox: "kw", Host: "nolimit.dk"}},
|
||||
"Kristian Wind <[email protected]>"},
|
||||
// The first sender wins. A mail from two people is rare enough that naming
|
||||
// one is better than a task titled with a list.
|
||||
{"two senders", []imap.Address{
|
||||
{Mailbox: "first", Host: "example.dk"},
|
||||
{Mailbox: "second", Host: "example.dk"},
|
||||
}, "[email protected]"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := address(c.in); got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The snippet is what shows under a task's title, so its only real duty is to end.
|
||||
func TestASnippetIsTrimmedAndBounded(t *testing.T) {
|
||||
long := strings.Repeat("a", snippetLimit*2)
|
||||
m := &imapclient.FetchMessageBuffer{
|
||||
BodySection: []imapclient.FetchBodySectionBuffer{
|
||||
{Bytes: []byte(" ")},
|
||||
{Bytes: []byte(" " + long + " ")},
|
||||
},
|
||||
}
|
||||
got := snippet(m)
|
||||
if strings.HasPrefix(got, " ") || strings.HasSuffix(got, " ") {
|
||||
t.Errorf("the snippet is not trimmed: %q", got[:20])
|
||||
}
|
||||
// An empty part must not win over the one that has the text.
|
||||
if !strings.HasPrefix(got, "aaa") {
|
||||
t.Errorf("an empty body part was preferred: %q", got)
|
||||
}
|
||||
if len([]rune(got)) > snippetLimit+1 {
|
||||
t.Errorf("the snippet runs to %d characters", len([]rune(got)))
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Error("a cut snippet does not say that it was cut")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyMailboxIsNotAnError(t *testing.T) {
|
||||
if got := snippet(&imapclient.FetchMessageBuffer{}); got != "" {
|
||||
t.Errorf("a mail with no body parts gave %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
|
||||
)
|
||||
|
||||
// The round trip, against a real IMAP server rather than a stub of one. The
|
||||
// earlier tests cover the pure parts — what a sender renders as, where a snippet
|
||||
// is cut — and would all have passed with the fetch itself broken.
|
||||
func TestFlaggedMailComesBackAsMessages(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura 4711", from: "Anders Bo <[email protected]>", body: "Vedhæftet.", flagged: true},
|
||||
{subject: "Nyhedsbrev", from: "[email protected]", body: "Læs mere", flagged: false},
|
||||
{subject: "Kontrakt", from: "[email protected]", body: "Til underskrift", flagged: true},
|
||||
})
|
||||
|
||||
client, err := Dial(Account{
|
||||
Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two of the three. The newsletter is unflagged, and flagging is the whole
|
||||
// signal: unread is a state the owner is still using.
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("got %d messages, want the 2 flagged ones", len(msgs))
|
||||
}
|
||||
if msgs[0].Subject != "Faktura 4711" {
|
||||
t.Errorf("first subject is %q", msgs[0].Subject)
|
||||
}
|
||||
if !strings.Contains(msgs[0].From, "[email protected]") {
|
||||
t.Errorf("first sender is %q", msgs[0].From)
|
||||
}
|
||||
if msgs[0].UID == 0 {
|
||||
t.Error("no uid came back, so a second run would fetch everything again")
|
||||
}
|
||||
|
||||
// And reading on from a marker returns only what is newer, which is the whole
|
||||
// of the deduplication.
|
||||
rest, _, err := client.Since(msgs[0].UID, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rest) != 1 || rest[0].Subject != "Kontrakt" {
|
||||
t.Fatalf("reading on from uid %d gave %d messages", msgs[0].UID, len(rest))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongPasswordIsRefusedClearly(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, nil)
|
||||
_, err := Dial(Account{Host: addr, Username: "kw", Password: "forkert", RootCAs: pool})
|
||||
if err == nil {
|
||||
t.Fatal("a wrong password was accepted")
|
||||
}
|
||||
// Hosts say everything from AUTHENTICATIONFAILED to a sentence about the web,
|
||||
// so the reply names the host and the user rather than repeating their words.
|
||||
if !strings.Contains(err.Error(), "kw") {
|
||||
t.Errorf("the error does not say whose login was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A certificate this process signed itself must not be enough on its own.
|
||||
func TestAnUntrustedCertificateIsRefused(t *testing.T) {
|
||||
addr, _ := serveMailbox(t, nil)
|
||||
if _, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig"}); err == nil {
|
||||
t.Fatal("a self-signed certificate was accepted without being trusted")
|
||||
}
|
||||
}
|
||||
|
||||
type mail struct {
|
||||
subject, from, body string
|
||||
flagged bool
|
||||
// raw replaces the whole message when set, which is the only way to get a
|
||||
// genuinely multipart one in.
|
||||
raw string
|
||||
}
|
||||
|
||||
// serveMailbox starts an in-process IMAP server over TLS and returns its address
|
||||
// and the pool that trusts it.
|
||||
func serveMailbox(t *testing.T, mails []mail) (string, *x509.CertPool) {
|
||||
t.Helper()
|
||||
|
||||
cert, pool := selfSigned(t)
|
||||
memServer := imapmemserver.New()
|
||||
user := imapmemserver.NewUser("kw", "hemmelig")
|
||||
if err := user.Create("INBOX", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memServer.AddUser(user)
|
||||
|
||||
for _, m := range mails {
|
||||
flags := []imap.Flag{}
|
||||
if m.flagged {
|
||||
flags = append(flags, imap.FlagFlagged)
|
||||
}
|
||||
raw := m.raw
|
||||
if raw == "" {
|
||||
raw = "From: " + m.from + "\r\nSubject: " + m.subject +
|
||||
"\r\nDate: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + m.body + "\r\n"
|
||||
}
|
||||
if _, err := user.Append("INBOX", strings.NewReader(raw), &imap.AppendOptions{
|
||||
Flags: flags,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := imapserver.New(&imapserver.Options{
|
||||
NewSession: func(_ *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return memServer.NewSession(), nil, nil
|
||||
},
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||
InsecureAuth: false,
|
||||
})
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Serve(ln)
|
||||
|
||||
// "localhost", not the loopback address: the certificate names a host, and the
|
||||
// client checks that name — as it should.
|
||||
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
return net.JoinHostPort("localhost", port), pool
|
||||
}
|
||||
|
||||
func selfSigned(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(certPEM)
|
||||
return cert, pool
|
||||
}
|
||||
|
||||
// The bug this was written for: every press of "Fetch now" made the same mail
|
||||
// into a task again. The marker the next run starts from is the uid, and if it
|
||||
// comes back as zero the marker never moves.
|
||||
func TestEveryMessageCarriesAUID(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "En", from: "[email protected]", body: "en", flagged: true},
|
||||
{subject: "To", from: "[email protected]", body: "to", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.UID == 0 {
|
||||
t.Fatalf("%q came back without a uid; the marker would never move and "+
|
||||
"every run would make it into a task again", m.Subject)
|
||||
}
|
||||
}
|
||||
if msgs[0].UID == msgs[1].UID {
|
||||
t.Error("two messages share a uid")
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same report: the snippet under a task's title read
|
||||
// "--=_be71cb39… Content-Transfer-Encoding: 8bit Content-Type: text/plain".
|
||||
func TestAMultipartMailReadsAsItsText(t *testing.T) {
|
||||
const boundary = "=_be71cb3975bdfbc60a16327cb2f49c23"
|
||||
body := "This is a multi-part message.\r\n" +
|
||||
"--" + boundary + "\r\n" +
|
||||
"Content-Transfer-Encoding: 8bit\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n\r\n" +
|
||||
"Hej igen,\r\n\r\nVi har normalt 3 slags. :)\r\n" +
|
||||
"--" + boundary + "\r\n" +
|
||||
"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
|
||||
"<html><body><p>Hej igen</p></body></html>\r\n" +
|
||||
"--" + boundary + "--\r\n"
|
||||
|
||||
addr, pool := serveMailbox(t, []mail{{
|
||||
flagged: true,
|
||||
raw: "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n" +
|
||||
"From: [email protected]\r\nSubject: Re: Kaffebestilling\r\n" +
|
||||
"Date: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + body,
|
||||
}})
|
||||
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("got %d messages", len(msgs))
|
||||
}
|
||||
|
||||
got := msgs[0].Snippet
|
||||
for _, junk := range []string{boundary, "Content-Transfer-Encoding", "Content-Type", "<html>", "<p>"} {
|
||||
if strings.Contains(got, junk) {
|
||||
t.Errorf("the snippet still carries %q: %q", junk, got)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(got, "Hej igen") {
|
||||
t.Errorf("the snippet does not start with the text of the mail: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The marker is ours, not the server's.
|
||||
//
|
||||
// A mailbox read with a marker above everything in it must come back empty. The
|
||||
// query says so, but a server is free to read the range differently — one reads
|
||||
// `1272:*` as `0:1272` and hands back the lot, which is how the same mail became
|
||||
// a task every ten minutes while the marker stood still at 1271. The filter is
|
||||
// on this side now, so the invariant holds whatever comes back.
|
||||
func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "En", from: "[email protected]", body: "en", flagged: true},
|
||||
{subject: "To", from: "[email protected]", body: "to", flagged: true},
|
||||
{subject: "Tre", from: "[email protected]", body: "tre", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
all, highest, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("got %d messages on the first read, want 3", len(all))
|
||||
}
|
||||
|
||||
// Read again from the marker the first run would have written.
|
||||
rest, again, err := client.Since(highest, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Errorf("%d message(s) came back at or below the marker: %v", len(rest), rest[0].Subject)
|
||||
}
|
||||
// And the marker must not go backwards, or the next run reads them all again.
|
||||
if again < highest {
|
||||
t.Errorf("the marker moved back from %d to %d", highest, again)
|
||||
}
|
||||
|
||||
// Every message, individually: none of them may reappear once passed.
|
||||
for _, m := range all {
|
||||
later, _, err := client.Since(m.UID, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, l := range later {
|
||||
if l.UID <= m.UID {
|
||||
t.Errorf("reading from %d gave %d back", m.UID, l.UID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Den samme mail må ikke kunne blive en opgave to gange.
|
||||
//
|
||||
// Prøven kører hele vejen imod en rigtig server: den finder de flagede mails,
|
||||
// tager flaget af dem, og spørger så forfra som en ny synkronisering ville — med
|
||||
// markøren sat til nul, altså det værst tænkelige tilfælde, hvor alt, hvad vi
|
||||
// selv havde husket, er væk. Der skal ikke komme noget tilbage.
|
||||
//
|
||||
// Det er det, markøren ikke kunne love. Den holdt kun, hvis uids var, hvad
|
||||
// serveren sagde, og fejlede på et fetch-svar uden uid, på en UIDVALIDITY, der
|
||||
// skiftede, og på to postkasse-rækker, der pegede samme sted og hver især førte
|
||||
// deres eget regnskab. Alle tre gav det samme: mailen blev en opgave igen.
|
||||
func TestAnUnflaggedMailDoesNotComeBack(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura 4711", from: "[email protected]", body: "Vedhæftet.", flagged: true},
|
||||
{subject: "Nyhedsbrev", from: "[email protected]", body: "Læs mere", flagged: false},
|
||||
{subject: "Kontrakt", from: "[email protected]", body: "Til underskrift", flagged: true},
|
||||
})
|
||||
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("fandt %d flagede mails, ventede 2", len(msgs))
|
||||
}
|
||||
|
||||
uids := make([]uint32, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
uids = append(uids, m.UID)
|
||||
}
|
||||
if err := client.Unflag(uids); err != nil {
|
||||
t.Fatalf("Unflag: %v", err)
|
||||
}
|
||||
|
||||
// Forfra, og fra nul: selv en instans, der intet husker, må ikke finde dem igen.
|
||||
again, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("%d mails kom igen efter at flaget var taget af — den første af dem er %q",
|
||||
len(again), again[0].Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tomt kald må ikke åbne mappen skrivbar for ingenting.
|
||||
func TestUnflagWithNothingToDoIsQuiet(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura", from: "[email protected]", body: "x", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Unflag(nil); err != nil {
|
||||
t.Errorf("Unflag(nil) = %v", err)
|
||||
}
|
||||
// Kun nuller: der er intet at adressere, og mailen skal beholde sit flag.
|
||||
if err := client.Unflag([]uint32{0, 0}); err != nil {
|
||||
t.Errorf("Unflag(nuller) = %v", err)
|
||||
}
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Errorf("mailen mistede sit flag, uden at nogen havde adresseret den")
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,23 @@ type Runner struct {
|
||||
// startable from main.
|
||||
SyncGmail func(ctx context.Context, user *store.User) (int, error)
|
||||
|
||||
// SyncMailbox reads one IMAP mailbox. Supplied by the HTTP layer for the same
|
||||
// reason as SyncGmail: it owns turning a message into a task.
|
||||
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
||||
|
||||
// SyncCalendars refreshes one person's connected calendars. Supplied the same
|
||||
// way and for the same reason: the HTTP layer owns the token refresh.
|
||||
SyncCalendars func(ctx context.Context, user *store.User) (int, error)
|
||||
|
||||
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
// SendBeacon reports this installation to the collector, if it is switched on
|
||||
// and a day has passed. Supplied by the HTTP layer for the same reason as the
|
||||
// syncs: that is where the instance settings and the version live.
|
||||
SendBeacon func(ctx context.Context) error
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -55,11 +72,23 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
||||
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
||||
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
|
||||
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
|
||||
// Ten minutes is a compromise: Gmail's push notifications would be instant but
|
||||
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||
// starred and slow enough not to look like abuse.
|
||||
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
|
||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||
// Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here
|
||||
// becomes a task, so being a quarter of an hour behind on a meeting next
|
||||
// Tuesday costs nothing — and the "fetch now" button covers the one case where
|
||||
// it matters, which is somebody who has just moved something and is looking at
|
||||
// the grid to check. A person who has just connected does not wait for this
|
||||
// either; connecting fetches the list, and the first sync is one click away.
|
||||
r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars)
|
||||
// Checked hourly, sent at most daily — the same shape as the backup, and for
|
||||
// the same reason: a container asleep at the scheduled minute would otherwise
|
||||
// skip the day. SendBeacon decides whether a day has passed.
|
||||
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -94,6 +123,17 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
|
||||
}()
|
||||
}
|
||||
|
||||
// sendBeacon reports that this installation exists, at most once a day.
|
||||
//
|
||||
// Every decision about what is sent, and why this is on by default rather than
|
||||
// compulsory, lives in internal/beacon. This is only the clock.
|
||||
func (r *Runner) sendBeacon(ctx context.Context) error {
|
||||
if r.SendBeacon == nil {
|
||||
return nil
|
||||
}
|
||||
return r.SendBeacon(ctx)
|
||||
}
|
||||
|
||||
// --- reminders ------------------------------------------------------------------
|
||||
|
||||
// sendDueReminders delivers everything that has come due since the last pass.
|
||||
@@ -121,6 +161,15 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
|
||||
"due": rem.RemindAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// And to the devices that asked for it. This was missing: reminders went out
|
||||
// in-app and by mail, so somebody with the tab closed and no mail configured
|
||||
// got a notification that never arrived — which is the case a reminder is
|
||||
// for. Notifications from other people's actions did push; only the timed
|
||||
// ones did not, and the interface said "on for this device" either way.
|
||||
if r.Push != nil {
|
||||
r.Push(rem.UserID, rem.TaskContent, reminderBody(rem.RemindAt), rem.ProjectID)
|
||||
}
|
||||
|
||||
if r.mail.Configured() && rem.UserEmail != "" {
|
||||
link := r.cfg.BaseURL + "/projekt/" + rem.ProjectID
|
||||
if err := r.mail.SendReminder(ctx, rem.UserEmail, rem.UserName, rem.TaskContent, link); err != nil {
|
||||
@@ -134,6 +183,12 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reminderBody is the line under the task's title. The time rather than the date:
|
||||
// a reminder arrives when it is due, so the day is today and saying so is noise.
|
||||
func reminderBody(at time.Time) string {
|
||||
return at.Format("15:04")
|
||||
}
|
||||
|
||||
// --- backups ---------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
@@ -161,22 +216,41 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
||||
if time.Since(last) < 23*time.Hour {
|
||||
return nil
|
||||
}
|
||||
return Backup(ctx, r.cfg, r.db, r.log)
|
||||
}
|
||||
|
||||
// Backup takes one snapshot now, records the run and rotates the old files.
|
||||
//
|
||||
// Exported and free of the Runner, because the settings page can ask for one too:
|
||||
// waiting until tonight to find out whether backups work at all is how somebody
|
||||
// discovers on the day they need one that the volume has been read-only for a
|
||||
// month. The nightly job is this function behind a "has one run today" check; the
|
||||
// button is this function without it.
|
||||
//
|
||||
// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a
|
||||
// live WAL-mode database can miss the most recent writes or catch a page
|
||||
// mid-update; VACUUM INTO produces a consistent, already-compacted database
|
||||
// without blocking writers for the duration.
|
||||
func Backup(ctx context.Context, cfg *config.Config, db *store.DB, log *slog.Logger) error {
|
||||
started := time.Now()
|
||||
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
||||
path := filepath.Join(cfg.BackupsDir(), name)
|
||||
|
||||
runID, err := r.db.StartBackupRun(ctx, started)
|
||||
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
|
||||
return fmt.Errorf("backups dir: %w", err)
|
||||
}
|
||||
|
||||
runID, err := db.StartBackupRun(ctx, started)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.db.Snapshot(ctx, path); err != nil {
|
||||
if err := db.Snapshot(ctx, path); err != nil {
|
||||
// A failed attempt can leave a partial file, which would then look like a
|
||||
// backup to anybody reading the directory.
|
||||
os.Remove(path)
|
||||
if recErr := r.db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
||||
r.log.Warn("record backup failure", "err", recErr)
|
||||
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
||||
log.Warn("record backup failure", "err", recErr)
|
||||
}
|
||||
return fmt.Errorf("snapshot: %w", err)
|
||||
}
|
||||
@@ -185,13 +259,13 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||
r.log.Warn("record backup", "err", err)
|
||||
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||
log.Warn("record backup", "err", err)
|
||||
}
|
||||
r.log.Info("backup written", "path", path, "bytes", size,
|
||||
log.Info("backup written", "path", path, "bytes", size,
|
||||
"took", time.Since(started).Round(time.Millisecond).String())
|
||||
|
||||
return r.rotateBackups()
|
||||
return rotateBackups(cfg, log)
|
||||
}
|
||||
|
||||
// rotateBackups keeps the most recent fourteen and deletes the rest.
|
||||
@@ -199,8 +273,8 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
||||
// Counted by file, not by age: a container that was off for a month would
|
||||
// otherwise come back, find every backup older than fourteen days, and delete all
|
||||
// of them — leaving none at exactly the moment they were most likely to be needed.
|
||||
func (r *Runner) rotateBackups() error {
|
||||
entries, err := os.ReadDir(r.cfg.BackupsDir())
|
||||
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
|
||||
entries, err := os.ReadDir(cfg.BackupsDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -215,38 +289,103 @@ func (r *Runner) rotateBackups() error {
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
|
||||
|
||||
for _, name := range backups[min(len(backups), backupKeepDays):] {
|
||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
||||
path := filepath.Join(cfg.BackupsDir(), name)
|
||||
if err := os.Remove(path); err != nil {
|
||||
r.log.Warn("remove old backup", "err", err, "path", path)
|
||||
log.Warn("remove old backup", "err", err, "path", path)
|
||||
continue
|
||||
}
|
||||
r.log.Info("old backup removed", "path", path)
|
||||
log.Info("old backup removed", "path", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- gmail ---------------------------------------------------------------------------
|
||||
|
||||
// syncGmailAccounts polls every connected mailbox.
|
||||
// syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
|
||||
//
|
||||
// One at a time, and a failure on one account does not stop the others: a revoked
|
||||
// token or an expired refresh is a problem for that person alone.
|
||||
func (r *Runner) syncGmailAccounts(ctx context.Context) error {
|
||||
if r.SyncGmail == nil {
|
||||
// One sweep rather than two, now that both live in the same table. Nobody is
|
||||
// waiting, so no budget of its own — the request handler has one because a person
|
||||
// is watching a spinner. A host that has gone quiet costs this run its own dial
|
||||
// timeout and nothing else: the loop moves on, per mailbox and per person, so one
|
||||
// bad password does not stop everybody else's mail.
|
||||
func (r *Runner) syncMailboxes(ctx context.Context) error {
|
||||
if r.SyncMailbox == nil {
|
||||
return nil
|
||||
}
|
||||
users, err := r.db.UsersWithGmail(ctx)
|
||||
userIDs, err := r.db.UsersWithMailboxes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
created, err := r.SyncGmail(ctx, &user)
|
||||
if err != nil {
|
||||
r.log.Warn("gmail sync", "err", err, "user", user.ID)
|
||||
for _, id := range userIDs {
|
||||
user, err := r.db.UserByID(ctx, id)
|
||||
if err != nil || user == nil {
|
||||
continue
|
||||
}
|
||||
if created > 0 {
|
||||
r.log.Info("tasks created from gmail", "user", user.ID, "count", created)
|
||||
boxes, err := r.db.Mailboxes(ctx, id)
|
||||
if err != nil {
|
||||
r.log.Warn("list mailboxes", "err", err, "user", id)
|
||||
continue
|
||||
}
|
||||
for i := range boxes {
|
||||
var created int
|
||||
var err error
|
||||
// Gmail is fetched over HTTP with a token, IMAP over a connection with a
|
||||
// password. Which one is a property of the row, not of the loop.
|
||||
if boxes[i].Kind == "gmail" {
|
||||
if r.SyncGmail == nil {
|
||||
continue
|
||||
}
|
||||
created, err = r.SyncGmail(ctx, user)
|
||||
} else {
|
||||
created, err = r.SyncMailbox(ctx, user, &boxes[i])
|
||||
}
|
||||
if err != nil {
|
||||
r.log.Warn("mailbox sync", "err", err, "user", id,
|
||||
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
|
||||
continue
|
||||
}
|
||||
if created > 0 {
|
||||
r.log.Info("tasks created from a mailbox", "user", id,
|
||||
"kind", boxes[i].Kind, "count", created)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- calendars ------------------------------------------------------------------------
|
||||
|
||||
// syncCalendars refreshes everybody's connected calendars.
|
||||
//
|
||||
// Its own sweep rather than a branch inside the mailbox one. They share nothing
|
||||
// past the word "poll": that loop asks each mailbox how far it has read and makes
|
||||
// tasks, this one replaces a window of somebody else's events. Folding them
|
||||
// together would mean one interval for two questions that do not want the same
|
||||
// answer, and a Google that is down would count against a mailbox that is not.
|
||||
//
|
||||
// Nobody is waiting, so no budget of its own — the request handler has one because
|
||||
// a person is watching a spinner. One account that has gone quiet costs this run
|
||||
// its own timeout and nothing else: the loop moves on per person.
|
||||
func (r *Runner) syncCalendars(ctx context.Context) error {
|
||||
if r.SyncCalendars == nil {
|
||||
return nil
|
||||
}
|
||||
userIDs, err := r.db.UsersWithCalendars(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
user, err := r.db.UserByID(ctx, id)
|
||||
if err != nil || user == nil {
|
||||
continue
|
||||
}
|
||||
written, err := r.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
r.log.Warn("calendar sync", "err", err, "user", id)
|
||||
continue
|
||||
}
|
||||
if written > 0 {
|
||||
r.log.Info("calendar refreshed", "user", id, "events", written)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -265,6 +404,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer
|
||||
// "what happened last week" and short enough that a fault loop cannot fill the
|
||||
// volume the tasks live on.
|
||||
func (r *Runner) purgeErrors(ctx context.Context) error {
|
||||
n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
r.log.Debug("old error rows removed", "count", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) purgeSessions(ctx context.Context) error {
|
||||
n, err := r.db.PurgeExpiredSessions(ctx)
|
||||
if err != nil {
|
||||
@@ -276,27 +429,12 @@ func (r *Runner) purgeSessions(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunBackupNow is what the "back up now" button calls, and what the tests use.
|
||||
// RunBackupNow is the Runner's way in, for the tests that already hold one.
|
||||
//
|
||||
// It was a second copy of Backup — written when the button that would call it did
|
||||
// not exist yet — and the two had already drifted: this one never created the
|
||||
// backups directory, and logged nothing. One implementation, so a fix to the
|
||||
// nightly path is a fix to the button as well.
|
||||
func (r *Runner) RunBackupNow(ctx context.Context) error {
|
||||
started := time.Now()
|
||||
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
||||
|
||||
runID, err := r.db.StartBackupRun(ctx, started)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.db.Snapshot(ctx, path); err != nil {
|
||||
os.Remove(path)
|
||||
_ = r.db.FinishBackupRun(ctx, runID, "", 0, err)
|
||||
return err
|
||||
}
|
||||
var size int64
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.rotateBackups()
|
||||
return Backup(ctx, r.cfg, r.db, r.log)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestBackupsRotateByCountNotAge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := runner.rotateBackups(); err != nil {
|
||||
if err := rotateBackups(cfg, runner.log); err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
|
||||
@@ -276,3 +276,59 @@ func backupFiles(t *testing.T, cfg *config.Config) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A reminder that nobody receives is not a reminder.
|
||||
//
|
||||
// The job sent them in-app and by mail. Somebody with the tab closed and no mail
|
||||
// server configured — which is the setup the docker one-liner produces — got
|
||||
// nothing, while the settings page said notifications were on for the device.
|
||||
// The push half was simply never wired: notifications from other people's actions
|
||||
// went out, only the timed ones did not.
|
||||
func TestADueReminderReachesTheDevices(t *testing.T) {
|
||||
runner, db, _ := newRunner(t)
|
||||
user := seedUser(t, db)
|
||||
|
||||
type sent struct{ userID, title, projectID string }
|
||||
var got []sent
|
||||
runner.Push = func(userID, title, body, projectID string) {
|
||||
got = append(got, sent{userID, title, projectID})
|
||||
}
|
||||
|
||||
inbox, err := db.InboxID(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "ring til Anders", CreatedBy: user.ID}
|
||||
if err := db.CreateTask(t.Context(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A minute ago, so it is due rather than upcoming.
|
||||
at := time.Now().Add(-time.Minute)
|
||||
if _, err := db.CreateReminder(t.Context(), task.ID, user.ID, &at, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runner.sendDueReminders(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%d reminders reached the devices, want 1", len(got))
|
||||
}
|
||||
if got[0].userID != user.ID {
|
||||
t.Errorf("it went to %q", got[0].userID)
|
||||
}
|
||||
if got[0].title != "ring til Anders" {
|
||||
t.Errorf("the title is %q, not the task", got[0].title)
|
||||
}
|
||||
|
||||
// And once. A reminder that arrives on every sweep is worse than one that does
|
||||
// not arrive at all.
|
||||
got = nil
|
||||
if err := runner.sendDueReminders(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the same reminder went out %d more times", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
KindTime Kind = "time"
|
||||
KindPriority Kind = "priority"
|
||||
KindProject Kind = "project"
|
||||
KindSection Kind = "section"
|
||||
KindLabel Kind = "label"
|
||||
KindRepeat Kind = "repeat"
|
||||
)
|
||||
@@ -52,7 +53,11 @@ type Result struct {
|
||||
Content string `json:"content"`
|
||||
// Project is the name written after '#', not an id: the caller resolves it
|
||||
// against the user's projects and decides what to do when it matches nothing.
|
||||
Project string `json:"project,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
// Section is the name written after '/', resolved by the caller against the
|
||||
// sections of whichever project the task lands in. A section belongs to one
|
||||
// project, so it cannot be looked up until the project is known.
|
||||
Section string `json:"section,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
||||
Priority int `json:"priority"`
|
||||
@@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
|
||||
// called "Fredag" from later being read as a weekday.
|
||||
res.Project, spans = extractProject(input, spans)
|
||||
res.Section, spans = extractSection(input, spans)
|
||||
res.Labels, spans = extractLabels(input, spans)
|
||||
res.Priority, spans = extractPriority(input, spans)
|
||||
|
||||
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
var (
|
||||
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
|
||||
//
|
||||
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
|
||||
// name — so the bare form is greedy and swallows the lot.
|
||||
//
|
||||
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
|
||||
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
|
||||
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
|
||||
// three are the shapes a slash actually takes in a Danish task line.
|
||||
reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
)
|
||||
|
||||
func extractProject(input string, spans []Span) (string, []Span) {
|
||||
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
|
||||
return name, spans
|
||||
}
|
||||
|
||||
// Last one wins, as with the project: retyping is a correction, not two sections.
|
||||
func extractSection(input string, spans []Span) (string, []Span) {
|
||||
all := reSection.FindAllStringSubmatchIndex(input, -1)
|
||||
if len(all) == 0 {
|
||||
return "", spans
|
||||
}
|
||||
var name string
|
||||
for _, m := range all {
|
||||
name = submatch(input, m)
|
||||
spans = append(spans, sigilSpan(input, m, KindSection))
|
||||
}
|
||||
return name, spans
|
||||
}
|
||||
|
||||
func extractLabels(input string, spans []Span) ([]string, []Span) {
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
|
||||
t.Error("no span of kind repeat")
|
||||
}
|
||||
}
|
||||
|
||||
// A section, and the three shapes a slash takes in ordinary text.
|
||||
//
|
||||
// The sigil is the one that needed thinking about: '#' and '@' never occur in a
|
||||
// Danish task line by accident, and '/' occurs constantly. What keeps it apart is
|
||||
// that the slash must follow whitespace and the name must follow the slash with
|
||||
// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail.
|
||||
func TestASectionIsWrittenWithASlash(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
section string
|
||||
content string
|
||||
}{
|
||||
{"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"},
|
||||
{"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"},
|
||||
// A real section name with a slash in it. The bare form is greedy, so the
|
||||
// whole thing comes across rather than stopping at the second slash.
|
||||
{"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"},
|
||||
{`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"},
|
||||
// Retyping is a correction, not two sections.
|
||||
{"nye poser /Drift /Produktion", "Produktion", "nye poser"},
|
||||
// And alongside everything else.
|
||||
{"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"},
|
||||
|
||||
// The ways a slash shows up when nobody meant a section. None of them may
|
||||
// produce one: the slash has to follow whitespace, and the name has to
|
||||
// follow the slash with nothing between.
|
||||
{"kør 80 km/t forbi", "", "kør 80 km/t forbi"},
|
||||
{"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"},
|
||||
// "3/4" is a section to nobody and a date to a Dane — the 3rd of April —
|
||||
// and the date parser is right to claim it. What matters here is only that
|
||||
// it did not become a section.
|
||||
{"læs 3/4 af rapporten", "", "læs af rapporten"},
|
||||
} {
|
||||
got := Parse(c.in, ref(t), "da")
|
||||
if got.Section != c.section {
|
||||
t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section)
|
||||
}
|
||||
if got.Content != c.content {
|
||||
t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package safedial refuses to open a connection to this machine or to the
|
||||
// network it sits on.
|
||||
//
|
||||
// The problem it solves is the same one three times: somewhere a user supplies a
|
||||
// URL or a host, and the server fetches it. An AI provider's base address, a push
|
||||
// endpoint, an IMAP host, a beacon collector. Each of them is a feature, and each
|
||||
// of them is also a way to ask the server to reach something the caller cannot —
|
||||
// the panel next door, a metadata endpoint, a database on the same bridge — and
|
||||
// to report back whether it answered. On a homelab that is the whole point of the
|
||||
// box being where it is.
|
||||
//
|
||||
// # Why the check is on the address and not on the URL
|
||||
//
|
||||
// Parsing the URL and refusing "localhost" or "127.0.0.1" is the obvious version
|
||||
// and it does not work: a name resolves to whatever its owner says, so
|
||||
// `evil.example.com` can answer 127.0.0.1 — and can answer differently the second
|
||||
// time, after a parse-time check has passed. That is DNS rebinding, and the only
|
||||
// place it can be caught is after resolution and before the connection, which is
|
||||
// exactly where net.Dialer's Control hook runs.
|
||||
//
|
||||
// # What is refused
|
||||
//
|
||||
// Loopback, link-local (including 169.254.169.254, which is where cloud metadata
|
||||
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
||||
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
||||
// wall around the inside.
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
||||
// deliberately does not say which address it resolved to: the answer is itself
|
||||
// the thing an attacker is fishing for.
|
||||
type ErrPrivate struct{ Host string }
|
||||
|
||||
func (e *ErrPrivate) Error() string {
|
||||
return fmt.Sprintf("refusing to connect to %s: it resolves to an address on this machine or its private network", e.Host)
|
||||
}
|
||||
|
||||
// Blocked reports whether an address must not be dialled.
|
||||
func Blocked(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsPrivate()
|
||||
}
|
||||
|
||||
// Control is a net.Dialer Control function that refuses a private destination.
|
||||
//
|
||||
// It runs after the name has been resolved and before the socket is connected,
|
||||
// which is the only moment where the address that will actually be used is known.
|
||||
func Control(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if Blocked(ip) {
|
||||
return &ErrPrivate{Host: host}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dialer is a net.Dialer that will not reach inside.
|
||||
func Dialer(timeout time.Duration) *net.Dialer {
|
||||
return &net.Dialer{Timeout: timeout, Control: Control}
|
||||
}
|
||||
|
||||
// Client is an http.Client for fetching something a user named.
|
||||
//
|
||||
// Redirects are followed but capped, and every hop goes through the same dialer —
|
||||
// a redirect to 127.0.0.1 is the oldest way around a check that only looked at the
|
||||
// URL somebody typed.
|
||||
func Client(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: Dialer(timeout).DialContext,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AllowPrivate is the same client without the wall, for a destination the
|
||||
// operator configured rather than a user — the panel, and nothing else. Named so
|
||||
// that using it is a decision somebody wrote down.
|
||||
func AllowPrivate(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
|
||||
// CheckURL reports why an address must not be fetched, or "" if it may be.
|
||||
//
|
||||
// A convenience for saying no at the moment somebody types an address, rather
|
||||
// than later when a request quietly fails. It is NOT the protection — a name can
|
||||
// resolve one way now and another way when it is dialled, which is why Control
|
||||
// exists and runs every time. Anything this rejects is certainly wrong; anything
|
||||
// it accepts is merely not obviously wrong.
|
||||
func CheckURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "that is not a URL"
|
||||
}
|
||||
host := u.Hostname()
|
||||
if ip := net.ParseIP(host); ip != nil && Blocked(ip) {
|
||||
return "that address is on this machine or its private network"
|
||||
}
|
||||
// A bare name is looked up once so an obvious "localhost" is caught here. A
|
||||
// failure to resolve is not an error: the host may simply not exist yet, and
|
||||
// refusing to save an address because DNS was slow would be its own bug.
|
||||
if ips, err := net.LookupIP(host); err == nil {
|
||||
for _, ip := range ips {
|
||||
if Blocked(ip) {
|
||||
return "that address is on this machine or its private network"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBlockedCoversTheInside(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "::1", "0.0.0.0",
|
||||
// Skyens metadata-endepunkt. Den ene adresse, enhver SSRF sigter efter.
|
||||
"169.254.169.254",
|
||||
"10.0.0.5", "172.16.0.1", "192.168.1.10", "fd00::1", "fe80::1",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if !Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"93.184.216.34", "8.8.8.8", "2606:2800:220:1:248:1893:25c8:1946"} {
|
||||
if Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = true, men det er en almindelig adresse", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En httptest-server lytter på loopback, hvilket gør den til den perfekte prøve:
|
||||
// den findes, den svarer, og den må ikke kunne nås.
|
||||
func TestClientRefusesLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("forbindelsen blev oprettet; den skulle være afvist")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Client(3 * time.Second).Get(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("en forespørgsel til loopback skal afvises")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowPrivateStillReaches(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := AllowPrivate(3 * time.Second).Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("den skal kunne nå det, operatøren selv har peget på: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||