Compare commits
@@ -197,6 +197,9 @@ to put one that the operator would ever see.
|
|||||||
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
||||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
||||||
|
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
|
||||||
|
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||||
|
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
||||||
|
|
||||||
## What lives in `/data`
|
## What lives in `/data`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
"github.com/kristianwind/verdande/internal/httpapi"
|
"github.com/kristianwind/verdande/internal/httpapi"
|
||||||
"github.com/kristianwind/verdande/internal/jobs"
|
"github.com/kristianwind/verdande/internal/jobs"
|
||||||
|
"github.com/kristianwind/verdande/internal/secret"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,15 @@ func run() error {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
log.Info("database ready", "path", db.Path())
|
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
|
httpapi.Version = version
|
||||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||||
|
|
||||||
@@ -64,6 +74,7 @@ func run() error {
|
|||||||
defer stopJobs()
|
defer stopJobs()
|
||||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||||
runner.SyncGmail = api.SyncGmail
|
runner.SyncGmail = api.SyncGmail
|
||||||
|
runner.SyncMailbox = api.SyncMailbox
|
||||||
runner.Start(jobCtx)
|
runner.Start(jobCtx)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
|||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||||
|
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||||
|
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||||
|
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||||
|
|
||||||
|
All three together let **Indstillinger → Notifikationer** restart this instance,
|
||||||
|
which is also how it picks up a new version: a container cannot replace its own
|
||||||
|
image, so the panel recreates it and pulls `:latest` on the way. Any one of the
|
||||||
|
three alone does nothing, and the page names the ones that are missing.
|
||||||
|
|
||||||
|
The token is a panel credential with control over that server. Anybody who can
|
||||||
|
read this environment can restart it — which is true of anybody with the host
|
||||||
|
anyway — and it is never sent to a browser.
|
||||||
|
|
||||||
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
||||||
at most every six hours and sends nothing about your instance — no identifier, no
|
at most every six hours and sends nothing about your instance — no identifier, no
|
||||||
|
|||||||
@@ -388,6 +388,195 @@ paths:
|
|||||||
"204": { description: Off. }
|
"204": { description: Off. }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/auth/passkeys:
|
||||||
|
get:
|
||||||
|
tags: [auth]
|
||||||
|
summary: The keys registered on this account.
|
||||||
|
description: |
|
||||||
|
A passkey is a key on a device rather than a secret in a head. 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.
|
||||||
|
|
||||||
|
One row per credential, because a person has a laptop and a phone and losing
|
||||||
|
one must not mean losing the way in. The name is theirs to write: a list
|
||||||
|
nobody can read is a list nobody revokes from.
|
||||||
|
|
||||||
|
Sessions only, like `/tokens` — a leaked API token must not be able to add
|
||||||
|
its own way in, which would turn a theft into a tenancy.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
passkeys:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Passkey" }
|
||||||
|
available:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Whether this deployment can offer passkeys at all. False when
|
||||||
|
`VERDANDE_BASE_URL` is an IP address or otherwise not a
|
||||||
|
domain an authenticator will accept — the interface asks once
|
||||||
|
rather than discovering it from a 503 it caused by offering a
|
||||||
|
button.
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/api/v1/auth/passkeys/register/begin:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Start registering a key. Returns the options and a challenge id.
|
||||||
|
description: |
|
||||||
|
Registration is done by somebody already signed in: you cannot be handed a
|
||||||
|
way into an account you have not proved you own.
|
||||||
|
|
||||||
|
The challenge is kept server-side rather than in a cookie, because the whole
|
||||||
|
point of it is that the server chose it and remembers choosing it. It is
|
||||||
|
answerable once and expires after five minutes.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Pass `options` to `navigator.credentials.create()`.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
challenge_id: { type: string }
|
||||||
|
options: { type: object, additionalProperties: true }
|
||||||
|
"503":
|
||||||
|
description: |
|
||||||
|
`VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
|
||||||
|
rather than 404: this version has passkeys, this deployment cannot offer
|
||||||
|
them, and those are different things to fix.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
|
||||||
|
/api/v1/auth/passkeys/register/finish:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Finish registering a key.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
challenge_id: { type: string }
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: What to call it in the list — "min bærbare".
|
||||||
|
credential:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: The response from `navigator.credentials.create()`.
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Registered.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Passkey" }
|
||||||
|
"400":
|
||||||
|
description: The attempt expired, was already used, or did not verify.
|
||||||
|
"503":
|
||||||
|
description: Passkeys are unavailable on this instance.
|
||||||
|
|
||||||
|
/api/v1/auth/passkeys/{passkeyID}:
|
||||||
|
parameters:
|
||||||
|
- { name: passkeyID, in: path, required: true, schema: { type: string } }
|
||||||
|
patch:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Rename a key.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
responses:
|
||||||
|
"204": { description: Renamed. }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
delete:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Remove a key.
|
||||||
|
description: |
|
||||||
|
Scoped in the SQL rather than checked first: an id read in one step and
|
||||||
|
deleted in the next is a window, and the reason somebody is on this screen
|
||||||
|
is usually that they think a device is gone.
|
||||||
|
responses:
|
||||||
|
"204": { description: Removed. }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/auth/passkey/login/begin:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Start signing in with a key. No session, and no email.
|
||||||
|
description: |
|
||||||
|
A discoverable login: 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
|
||||||
|
address to type and no list of accounts for anybody to probe.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Pass `options` to `navigator.credentials.get()`.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
challenge_id: { type: string }
|
||||||
|
options: { type: object, additionalProperties: true }
|
||||||
|
"429": { $ref: "#/components/responses/RateLimited" }
|
||||||
|
"503":
|
||||||
|
description: Passkeys are unavailable on this instance.
|
||||||
|
|
||||||
|
/api/v1/auth/passkey/login/finish:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Finish signing in with a key.
|
||||||
|
description: |
|
||||||
|
A key that verified its owner — a PIN, a fingerprint, a face — is both
|
||||||
|
factors at once, and completes the login outright. One that only proved
|
||||||
|
possession still stops at the TOTP step, exactly as a password does.
|
||||||
|
|
||||||
|
A counter that has gone backwards means the credential has been cloned. It
|
||||||
|
is refused rather than logged: that counter is the only signal this design
|
||||||
|
gives.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
challenge_id: { type: string }
|
||||||
|
credential:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: The response from `navigator.credentials.get()`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Signed in, or `totp_required` when the key was possession only.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
user: { $ref: "#/components/schemas/User" }
|
||||||
|
totp_required: { type: boolean }
|
||||||
|
"401":
|
||||||
|
description: |
|
||||||
|
One message for an unknown key and for a bad signature. Telling them
|
||||||
|
apart is a way to learn which credentials exist here.
|
||||||
|
"429": { $ref: "#/components/responses/RateLimited" }
|
||||||
|
"503":
|
||||||
|
description: Passkeys are unavailable on this instance.
|
||||||
|
|
||||||
/api/v1/auth/sessions:
|
/api/v1/auth/sessions:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
@@ -1148,6 +1337,67 @@ paths:
|
|||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/panel:
|
||||||
|
get:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Whether this instance can restart itself. Administrators only.
|
||||||
|
description: |
|
||||||
|
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.
|
||||||
|
|
||||||
|
This says whether the three settings are present. The token is never sent:
|
||||||
|
the page needs to know whether the button can work, not what the credential
|
||||||
|
is. When it cannot, the missing variables are named — an operator reading
|
||||||
|
"not configured" should not have to go and find out which of three.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
panel_url: { type: string }
|
||||||
|
missing:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: The environment variables that are not set.
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/api/v1/panel/restart:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Ask the panel to recreate this container. Administrators only.
|
||||||
|
description: |
|
||||||
|
The panel does the work and records who asked; this removes the second
|
||||||
|
browser tab, not the panel's authority.
|
||||||
|
|
||||||
|
Answers 202 rather than waiting: the panel stops this container as part of
|
||||||
|
answering, so the reply may never arrive. A request cut off mid-flight is
|
||||||
|
the successful case wearing a failure's clothes — the interface polls
|
||||||
|
`/healthz` rather than believing either answer.
|
||||||
|
|
||||||
|
Sessions only, like the rest of this group. A leaked API token that could
|
||||||
|
restart the server is a denial of service in one request.
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: The panel accepted, or was already restarting this container.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
restarting: { type: boolean }
|
||||||
|
note: { type: string }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"502":
|
||||||
|
description: |
|
||||||
|
The panel refused. Usually a token without control of this server.
|
||||||
|
"503":
|
||||||
|
description: The three settings are not all present.
|
||||||
|
|
||||||
/api/v1/errors:
|
/api/v1/errors:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
@@ -2227,6 +2477,86 @@ paths:
|
|||||||
created: { type: integer }
|
created: { type: integer }
|
||||||
"502": { $ref: "#/components/responses/Upstream" }
|
"502": { $ref: "#/components/responses/Upstream" }
|
||||||
|
|
||||||
|
/api/v1/mailboxes:
|
||||||
|
get:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: The mailboxes this person has connected.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
mailboxes:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Mailbox" }
|
||||||
|
post:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: Connect a mailbox over IMAP.
|
||||||
|
description: >
|
||||||
|
The connection is tested before it is saved, so a mailbox that cannot be
|
||||||
|
read is refused here rather than failing quietly every ten minutes.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [host, username, password]
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
host: { type: string, example: "imap.mail.me.com:993" }
|
||||||
|
username: { type: string }
|
||||||
|
password: { type: string, description: "An app-specific password on every host worth naming. Stored sealed." }
|
||||||
|
folder: { type: string, default: INBOX }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Connected
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/Mailbox" }
|
||||||
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
|
"409": { description: That mailbox is already connected }
|
||||||
|
"422": { $ref: "#/components/responses/Upstream" }
|
||||||
|
|
||||||
|
/api/v1/mailboxes/{mailboxID}:
|
||||||
|
delete:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: Disconnect a mailbox. The tasks it made stay.
|
||||||
|
parameters:
|
||||||
|
- name: mailboxID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"204": { description: Disconnected }
|
||||||
|
|
||||||
|
/api/v1/mailboxes/{mailboxID}/sync:
|
||||||
|
post:
|
||||||
|
tags: [integrations]
|
||||||
|
summary: Read one mailbox now.
|
||||||
|
parameters:
|
||||||
|
- name: mailboxID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
created: { type: integer }
|
||||||
|
partial:
|
||||||
|
type: boolean
|
||||||
|
description: The run hit its time budget. What it made is kept.
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Upstream" }
|
||||||
|
|
||||||
/api/v1/ai/settings:
|
/api/v1/ai/settings:
|
||||||
get:
|
get:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
@@ -2832,6 +3162,20 @@ components:
|
|||||||
items: { type: string }
|
items: { type: string }
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
Mailbox:
|
||||||
|
type: object
|
||||||
|
description: One connected mailbox. The password and any token are never returned.
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
kind: { type: string, enum: [gmail, imap] }
|
||||||
|
name: { type: string }
|
||||||
|
host: { type: string }
|
||||||
|
username: { type: string }
|
||||||
|
folder: { type: string }
|
||||||
|
last_uid: { type: integer }
|
||||||
|
last_sync_at: { type: string, format: date-time }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -3127,6 +3471,32 @@ components:
|
|||||||
same thing once decoded, and telling "put it nowhere" apart from "I
|
same thing once decoded, and telling "put it nowhere" apart from "I
|
||||||
did not mention it" is what makes this a PATCH.
|
did not mention it" is what makes this a PATCH.
|
||||||
|
|
||||||
|
Passkey:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: What the owner called it. "min bærbare" is what makes a list reviewable.
|
||||||
|
aaguid:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Identifies the authenticator model, which is what lets a list say
|
||||||
|
something recognisable beside a key somebody named months ago.
|
||||||
|
discoverable:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Whether the credential lives on the device. Only these can start a login
|
||||||
|
with no email typed first.
|
||||||
|
user_verified:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Whether the authenticator verified the person — a PIN, a fingerprint, a
|
||||||
|
face. One that did is both factors at once; one that only proved
|
||||||
|
possession is a first factor and still meets the TOTP step.
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
last_used_at: { type: string, format: date-time }
|
||||||
|
|
||||||
ProjectGroup:
|
ProjectGroup:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
@@ -3,21 +3,33 @@ module github.com/kristianwind/verdande
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
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-chi/chi/v5 v5.3.1
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
|
github.com/teambition/rrule-go v1.8.2
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
modernc.org/sqlite v1.56.0
|
modernc.org/sqlite v1.56.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/coder/websocket v1.8.15 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/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/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -2,12 +2,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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/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 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||||
|
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||||
|
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -18,6 +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/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
@@ -25,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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||||
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/mod v0.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 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/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 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
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 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
|||||||
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
|
|||||||
}
|
}
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The relying party is derived from the instance's own base URL, and the two ways
|
||||||
|
// that goes wrong are both silent until a browser refuses.
|
||||||
|
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
base string
|
||||||
|
ok bool
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"https://verdande.example.dk", true, "a real domain"},
|
||||||
|
{"http://localhost:5173", true, "localhost, which every browser excepts"},
|
||||||
|
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
|
||||||
|
|
||||||
|
// An IP address is not a domain. The browser throws SecurityError long
|
||||||
|
// after the server has happily issued a challenge, so it is refused here
|
||||||
|
// and the endpoints answer 503 instead.
|
||||||
|
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
|
||||||
|
{"http://[::1]:8080", false, "an IPv6 literal"},
|
||||||
|
{"", false, "no address at all"},
|
||||||
|
{"not a url", false, "not an address"},
|
||||||
|
} {
|
||||||
|
_, err := NewWebAuthn(c.base, "verdande")
|
||||||
|
if c.ok && err != nil {
|
||||||
|
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
|
||||||
|
}
|
||||||
|
if !c.ok && err == nil {
|
||||||
|
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passkeys, wrapped so the rest of the app never touches the protocol types.
|
||||||
|
//
|
||||||
|
// A password and a TOTP code are both shared secrets: the server keeps something
|
||||||
|
// that can be stolen from it, and the person keeps something that can be talked
|
||||||
|
// out of them. A passkey is neither. The private half never leaves the device, and
|
||||||
|
// the signature is bound to the origin that asked for it — so a convincing copy of
|
||||||
|
// the sign-in page at another address gets a signature it cannot use.
|
||||||
|
//
|
||||||
|
// That binding is also the one thing that can be got wrong here, and it is
|
||||||
|
// invisible when it is: the RP ID has to be the registrable domain of the address
|
||||||
|
// people actually visit. Get it wrong and registration works, login works on your
|
||||||
|
// machine, and everybody behind a different hostname is quietly locked out.
|
||||||
|
|
||||||
|
// WebAuthn is the relying party — this instance, as an authenticator sees it.
|
||||||
|
type WebAuthn struct{ *webauthn.WebAuthn }
|
||||||
|
|
||||||
|
// NewWebAuthn derives the relying party from the instance's own base URL.
|
||||||
|
//
|
||||||
|
// Derived rather than configured, because a second setting that has to agree with
|
||||||
|
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
|
||||||
|
// the failure is a login that cannot be completed, reported as "the button does
|
||||||
|
// nothing".
|
||||||
|
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The RP ID is the host without the port. A port in it is rejected by every
|
||||||
|
// browser, and localhost:5173 during development is exactly where somebody
|
||||||
|
// would first put one.
|
||||||
|
rpID := parsed.Hostname()
|
||||||
|
|
||||||
|
// An IP address is not a domain, and no browser will accept one as an RP ID —
|
||||||
|
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
|
||||||
|
// the server has happily issued a challenge. Refused here instead, so the
|
||||||
|
// endpoints answer 503 and the interface never offers a button that cannot
|
||||||
|
// work. `localhost` is the exception every browser makes, and it is what
|
||||||
|
// development runs on.
|
||||||
|
if ip := net.ParseIP(rpID); ip != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"auth: passkeys need a domain name, and %q is an IP address — "+
|
||||||
|
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := webauthn.New(&webauthn.Config{
|
||||||
|
RPID: rpID,
|
||||||
|
RPDisplayName: displayName,
|
||||||
|
// The origin is the full scheme://host:port, which is what the browser
|
||||||
|
// sends and what the signature is checked against.
|
||||||
|
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: passkeys: %w", err)
|
||||||
|
}
|
||||||
|
return &WebAuthn{w}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PasskeyUser is what the library needs to know about an account.
|
||||||
|
//
|
||||||
|
// The handle is the account id rather than anything a person chose. It is written
|
||||||
|
// into the credential on the device and cannot be changed afterwards, so it must
|
||||||
|
// not be an email address: changing your email would orphan every key you own.
|
||||||
|
type PasskeyUser struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Credentials []webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
|
||||||
|
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
|
||||||
|
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
|
||||||
|
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
|
||||||
|
|
||||||
|
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
|
||||||
|
// go in a BLOB well enough, but the id is also compared against what the browser
|
||||||
|
// sends back as base64url, and one encoding on both sides is one thing to be
|
||||||
|
// wrong about.
|
||||||
|
func EncodeCredentialID(raw []byte) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeCredentialID(encoded string) ([]byte, error) {
|
||||||
|
return base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
}
|
||||||
@@ -33,11 +33,41 @@ type Config struct {
|
|||||||
GmailClientID string
|
GmailClientID string
|
||||||
GmailClientSecret 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
|
// 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
|
// for: a self-hosted app that reaches out without being told to has broken
|
||||||
// the deal its operator made by self-hosting.
|
// the deal its operator made by self-hosting.
|
||||||
UpdateCheck bool
|
UpdateCheck bool
|
||||||
|
|
||||||
|
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||||
|
// from its own interface rather than from another browser tab.
|
||||||
|
//
|
||||||
|
// A container cannot replace its own image from the inside. Restarting is the
|
||||||
|
// panel's job — it recreates the container and pulls `:latest` on the way — so
|
||||||
|
// what this needs is a way to ask. All three are required together; any one
|
||||||
|
// alone cannot do anything.
|
||||||
|
//
|
||||||
|
// The token is a panel credential with control over that server. Anybody who
|
||||||
|
// can read this environment can restart it, which is true of anybody with the
|
||||||
|
// host anyway. It is never sent to a browser: the settings page is told only
|
||||||
|
// whether it is set.
|
||||||
|
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
|
||||||
|
// front of this server. Configurable so a test can prove the budget is applied
|
||||||
|
// without spending the real one on every run.
|
||||||
|
GmailSyncBudget time.Duration
|
||||||
|
|
||||||
|
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
|
||||||
|
// in production, where it means Google.
|
||||||
|
GmailTokenURL string
|
||||||
|
GmailAPIURL string
|
||||||
|
|
||||||
|
PanelURL string
|
||||||
|
PanelToken string
|
||||||
|
PanelServerID string
|
||||||
|
|
||||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||||
TrashRetention time.Duration
|
TrashRetention time.Duration
|
||||||
|
|
||||||
@@ -69,7 +99,12 @@ func Load() (*Config, error) {
|
|||||||
Dev: envBool("VERDANDE_DEV", false),
|
Dev: envBool("VERDANDE_DEV", false),
|
||||||
|
|
||||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||||
|
GmailSyncBudget: 25 * time.Second,
|
||||||
|
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||||
|
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||||
|
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||||
|
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,20 +25,50 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
tokenEndpoint = "https://oauth2.googleapis.com/token"
|
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||||
apiBase = "https://gmail.googleapis.com/gmail/v1"
|
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||||
|
|
||||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Endpoints is where Google is.
|
||||||
|
//
|
||||||
|
// A field rather than a constant so a test can point it at a server it controls.
|
||||||
|
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
||||||
|
// and "the sync comes back before whatever sits in front of this server gives up
|
||||||
|
// on it" is exactly the kind of promise worth a test and impossible to make
|
||||||
|
// against the real thing.
|
||||||
|
//
|
||||||
|
// The zero value is Google, so nothing outside a test has to know it exists.
|
||||||
|
type Endpoints struct {
|
||||||
|
Token string
|
||||||
|
API string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Endpoints) token() string {
|
||||||
|
if e.Token != "" {
|
||||||
|
return e.Token
|
||||||
|
}
|
||||||
|
return defaultTokenEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Endpoints) api() string {
|
||||||
|
if e.API != "" {
|
||||||
|
return e.API
|
||||||
|
}
|
||||||
|
return defaultAPIBase
|
||||||
|
}
|
||||||
|
|
||||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
// 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.
|
// the instance rather than to a person: one registration, any number of users.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ClientID string
|
ClientID string
|
||||||
ClientSecret string
|
ClientSecret string
|
||||||
RedirectURL string
|
RedirectURL string
|
||||||
|
// Endpoints is Google unless a test says otherwise.
|
||||||
|
Endpoints Endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) Configured() bool {
|
func (c Config) Configured() bool {
|
||||||
@@ -128,7 +158,7 @@ func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
||||||
strings.NewReader(form.Encode()))
|
strings.NewReader(form.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Token{}, err
|
return Token{}, err
|
||||||
@@ -171,6 +201,7 @@ func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
|||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
accessToken string
|
accessToken string
|
||||||
|
endpoints Endpoints
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +209,12 @@ func NewClient(accessToken string) *Client {
|
|||||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At points the client at another server. Tests only; the zero value is Google.
|
||||||
|
func (c *Client) At(e Endpoints) *Client {
|
||||||
|
c.endpoints = e
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// Message is the part of a Gmail message a task is made from.
|
// Message is the part of a Gmail message a task is made from.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -196,7 +233,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
|||||||
var parsed struct {
|
var parsed struct {
|
||||||
EmailAddress string `json:"emailAddress"`
|
EmailAddress string `json:"emailAddress"`
|
||||||
}
|
}
|
||||||
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
|
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return parsed.EmailAddress, nil
|
return parsed.EmailAddress, nil
|
||||||
@@ -220,7 +257,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"messages"`
|
} `json:"messages"`
|
||||||
}
|
}
|
||||||
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +288,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
|||||||
} `json:"headers"`
|
} `json:"headers"`
|
||||||
} `json:"payload"`
|
} `json:"payload"`
|
||||||
}
|
}
|
||||||
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -573,6 +573,43 @@ func inboxName(locale string) string {
|
|||||||
// 11:49" long after the line explaining it has gone, which tells an operator that
|
// 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
|
// something broke and gives them no way to find out what. The row outlives the
|
||||||
// container; Indstillinger → Fejl is where it is read.
|
// 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) {
|
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
s.log.Error(what, "err", err)
|
s.log.Error(what, "err", err)
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
|||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
BaseURL: "http://127.0.0.1",
|
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||||
|
// party id, and every browser makes the same exception for localhost.
|
||||||
|
BaseURL: "http://localhost",
|
||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
SessionTTL: 24 * time.Hour,
|
SessionTTL: 24 * time.Hour,
|
||||||
InviteTTL: 7 * 24 * time.Hour,
|
InviteTTL: 7 * 24 * time.Hour,
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ const (
|
|||||||
// is not "that already exists" either, and it is the one refusal on the user
|
// 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.
|
// page that a person needs explained rather than merely reported.
|
||||||
CodeLastAdmin = "last_admin"
|
CodeLastAdmin = "last_admin"
|
||||||
|
|
||||||
|
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
|
||||||
|
// "insufficient authentication scopes" — and they are useless behind a generic
|
||||||
|
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
|
||||||
|
// read "something went wrong", and had nowhere to go. The interface wraps the
|
||||||
|
// message in one that says where it came from.
|
||||||
|
CodeGmailFailed = "gmail_failed"
|
||||||
|
|
||||||
|
// And the same for a mailbox read over IMAP. A host's refusal is the whole
|
||||||
|
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
||||||
|
// is not there — and it says it in its own words.
|
||||||
|
CodeMailboxFailed = "mailbox_failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errLastAdmin is returned to the caller that already wrote the response, so it
|
// errLastAdmin is returned to the caller that already wrote the response, so it
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,11 @@ import (
|
|||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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.
|
// gmailConfig is the OAuth client this instance authorises with.
|
||||||
//
|
//
|
||||||
// The environment wins when it is set. A value in the environment is a deliberate
|
// The environment wins when it is set. A value in the environment is a deliberate
|
||||||
@@ -17,11 +23,20 @@ import (
|
|||||||
// applied on every recreate — and a form that could quietly override it would make
|
// 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
|
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
|
||||||
// to redeploy a container to paste a client id.
|
// to redeploy a container to paste a client id.
|
||||||
|
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||||
|
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||||
|
return gmail.NewClient(accessToken).At(gmail.Endpoints{
|
||||||
|
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||||
|
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
||||||
cfg := gmail.Config{
|
cfg := gmail.Config{
|
||||||
ClientID: s.cfg.GmailClientID,
|
ClientID: s.cfg.GmailClientID,
|
||||||
ClientSecret: s.cfg.GmailClientSecret,
|
ClientSecret: s.cfg.GmailClientSecret,
|
||||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||||
|
Endpoints: endpoints,
|
||||||
}
|
}
|
||||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||||
return cfg
|
return cfg
|
||||||
@@ -150,7 +165,10 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
s.log.Warn("gmail profile", "err", err)
|
s.log.Warn("gmail profile", "err", err)
|
||||||
}
|
}
|
||||||
@@ -182,9 +200,36 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFrom(r.Context())
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
||||||
@@ -229,7 +274,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := gmail.NewClient(access)
|
client := s.gmailClient(access)
|
||||||
ids, err := client.List(ctx, query, 25)
|
ids, err := client.List(ctx, query, 25)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gmail.ErrUnauthorized {
|
if err == gmail.ErrUnauthorized {
|
||||||
@@ -269,6 +314,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
if seen[id] {
|
if seen[id] {
|
||||||
continue
|
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)
|
msg, err := client.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||||
@@ -425,3 +475,39 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gmailProfile fetches the mailbox address and stores it, refreshing the access
|
||||||
|
// token first if it has expired.
|
||||||
|
//
|
||||||
|
// Shared by the connect callback and the status endpoint. The callback is the
|
||||||
|
// natural place to learn the address, and it is also the one moment that cannot be
|
||||||
|
// retried — so the status endpoint asks again when it finds nothing, and the answer
|
||||||
|
// corrects itself on the next look.
|
||||||
|
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) {
|
||||||
|
str := func(key string) string {
|
||||||
|
v, _ := settings[key].(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
access := str("access_token")
|
||||||
|
expires, _ := settings["expires_at"].(float64)
|
||||||
|
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||||
|
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
access = token.AccessToken
|
||||||
|
settings["access_token"] = access
|
||||||
|
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err := s.gmailClient(access).Profile(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
settings["email"] = email
|
||||||
|
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Gmail failure has to survive being reported.
|
||||||
|
//
|
||||||
|
// It used to be hidden twice over. The handler answered 502 with the code
|
||||||
|
// `internal_error`, which the interface maps to "something went wrong" — so
|
||||||
|
// Google's own words, the only thing that says what to do, were replaced by a
|
||||||
|
// sentence that says nothing. And `writeError` records nothing, so the error log
|
||||||
|
// was empty: a person clicked Fetch now, read a generic failure, and there was
|
||||||
|
// nowhere to look it up afterwards.
|
||||||
|
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||||
|
// A Gmail that refuses, the way an expired grant does.
|
||||||
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
defer google.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.GmailClientID = "test-client"
|
||||||
|
cfg.GmailClientSecret = "test-secret"
|
||||||
|
cfg.GmailTokenURL = google.URL + "/token"
|
||||||
|
cfg.GmailAPIURL = google.URL
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Connected, with a refresh token that will be refused.
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||||
|
"refresh_token": "spent", "trigger": "starred",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||||
|
if resp.StatusCode != StatusUpstreamRefused {
|
||||||
|
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["code"] != "gmail_failed" {
|
||||||
|
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
|
||||||
|
"sentence, which throws the diagnosis away", body["code"])
|
||||||
|
}
|
||||||
|
if msg, _ := body["error"].(string); msg == "" {
|
||||||
|
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it is in the error log, which is the point of having one: a sync that has
|
||||||
|
// been failing for a week is invisible otherwise.
|
||||||
|
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||||
|
rows, _ := logged["errors"].([]any)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
|
||||||
|
}
|
||||||
|
row := rows[0].(map[string]any)
|
||||||
|
if row["what"] != "sync gmail" {
|
||||||
|
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
||||||
|
}
|
||||||
|
if row["status"] != float64(StatusUpstreamRefused) {
|
||||||
|
t.Errorf("status = %v, want 502", row["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slow mailbox must not hold the request open until something in front of this
|
||||||
|
// server gives up on it.
|
||||||
|
//
|
||||||
|
// This is what was actually happening in production: each Gmail call has its own
|
||||||
|
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
|
||||||
|
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
|
||||||
|
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
|
||||||
|
// showed "something went wrong". The handler had never returned.
|
||||||
|
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||||
|
// A Gmail that never answers.
|
||||||
|
blocked := make(chan struct{})
|
||||||
|
defer close(blocked)
|
||||||
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-blocked:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer google.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.GmailClientID = "test-client"
|
||||||
|
cfg.GmailClientSecret = "test-secret"
|
||||||
|
cfg.GmailTokenURL = google.URL + "/token"
|
||||||
|
cfg.GmailAPIURL = google.URL
|
||||||
|
// A second rather than the real twenty-five: what is being tested is that a
|
||||||
|
// budget is applied at all, and spending the production one on every run
|
||||||
|
// would put half a minute into the suite to learn the same thing.
|
||||||
|
cfg.GmailSyncBudget = time.Second
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A token that has not expired, so the run goes straight to the message list
|
||||||
|
// rather than stopping at a refresh.
|
||||||
|
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||||
|
"refresh_token": "r", "access_token": "a", "trigger": "starred",
|
||||||
|
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}); 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
|
||||||
|
}
|
||||||
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|||||||
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("ai split", "err", err)
|
s.log.Warn("ai split", "err", err)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("ai summary", "err", err)
|
s.log.Warn("ai summary", "err", err)
|
||||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
||||||
@@ -379,9 +379,27 @@ func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
|||||||
v, _ := values[key].(string)
|
v, _ := values[key].(string)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
// The mailbox address, fetched again if it is missing.
|
||||||
|
//
|
||||||
|
// It is stored at connect time, and the call that fetches it can fail — a
|
||||||
|
// slow token, a scope Google had not applied yet. That failure used to be a
|
||||||
|
// log line and an empty string, so the page said "an unknown account" and went
|
||||||
|
// on saying it forever: the only way back was to disconnect and reconnect,
|
||||||
|
// which nobody would guess. Asking again here costs one request on a page
|
||||||
|
// nobody opens often, and it means the wrong answer lasts until the next look
|
||||||
|
// rather than until somebody reconnects.
|
||||||
|
email := str("email")
|
||||||
|
if email == "" && str("refresh_token") != "" {
|
||||||
|
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
|
||||||
|
email = fetched
|
||||||
|
} else {
|
||||||
|
s.log.Warn("gmail profile", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, gmailSettings{
|
writeJSON(w, http.StatusOK, gmailSettings{
|
||||||
Connected: str("refresh_token") != "",
|
Connected: str("refresh_token") != "",
|
||||||
Email: str("email"),
|
Email: email,
|
||||||
Trigger: str("trigger"),
|
Trigger: str("trigger"),
|
||||||
Label: str("label"),
|
Label: str("label"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1172,7 +1172,7 @@ func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// The single most likely thing to be wrong, so it is spelled out rather than
|
// The single most likely thing to be wrong, so it is spelled out rather than
|
||||||
// left for somebody to reconstruct.
|
// left for somebody to reconstruct.
|
||||||
if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
|
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
|
||||||
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
|
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1280,3 +1280,39 @@ func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
|||||||
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
|
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,211 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"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) {
|
||||||
|
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, err := client.Since(m.LastUID, 25)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, err := s.db.InboxID(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
created := 0
|
||||||
|
highest := m.LastUID
|
||||||
|
for _, msg := range messages {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
// What was made so far is already committed and counted, and the marker
|
||||||
|
// below has to be written for it: a run that created tasks and forgot how
|
||||||
|
// far it got would make them all again on the next pass.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := msg.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = "(uden emne)"
|
||||||
|
}
|
||||||
|
content := subject
|
||||||
|
if msg.From != "" {
|
||||||
|
content = msg.From + ": " + subject
|
||||||
|
}
|
||||||
|
|
||||||
|
task := &store.Task{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Content: content,
|
||||||
|
Description: msg.Snippet,
|
||||||
|
Priority: 4,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}
|
||||||
|
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||||
|
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
if msg.UID > highest {
|
||||||
|
highest = msg.UID
|
||||||
|
}
|
||||||
|
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Asking the panel to restart this instance.
|
||||||
|
//
|
||||||
|
// A container cannot replace its own image from the inside, so "update" is really
|
||||||
|
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
|
||||||
|
// the way, which is what makes a restart an update at all.
|
||||||
|
//
|
||||||
|
// The alternative was a second browser tab: read the version here, go to the
|
||||||
|
// panel, find the server, press Restart. This removes the tab, not the panel's
|
||||||
|
// authority — the panel still does the work and still records who asked.
|
||||||
|
//
|
||||||
|
// Administrators only, and sessions only. A leaked API token that could restart
|
||||||
|
// the server is a denial of service in one request, and the whole point of the
|
||||||
|
// session rule elsewhere in this file is that a stolen token must not be able to
|
||||||
|
// do things only a person at a keyboard should.
|
||||||
|
|
||||||
|
type panelStatusJSON struct {
|
||||||
|
// Configured is all three settings being present. The token itself is never
|
||||||
|
// sent: the page needs to know whether the button can work, not what the
|
||||||
|
// credential is.
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
PanelURL string `json:"panel_url,omitempty"`
|
||||||
|
// Why it cannot work, when it cannot. An operator reading "not configured"
|
||||||
|
// should not have to go and find out which of three things is missing.
|
||||||
|
Missing []string `json:"missing,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) panelStatus() panelStatusJSON {
|
||||||
|
var missing []string
|
||||||
|
if s.cfg.PanelURL == "" {
|
||||||
|
missing = append(missing, "VERDANDE_PANEL_URL")
|
||||||
|
}
|
||||||
|
if s.cfg.PanelToken == "" {
|
||||||
|
missing = append(missing, "VERDANDE_PANEL_TOKEN")
|
||||||
|
}
|
||||||
|
if s.cfg.PanelServerID == "" {
|
||||||
|
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
|
||||||
|
}
|
||||||
|
return panelStatusJSON{
|
||||||
|
Configured: len(missing) == 0,
|
||||||
|
PanelURL: s.cfg.PanelURL,
|
||||||
|
Missing: missing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, s.panelStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := s.panelStatus()
|
||||||
|
if !status.Configured {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||||
|
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
|
||||||
|
|
||||||
|
// A short timeout of its own. The panel stops this container as part of
|
||||||
|
// answering, so the reply may never arrive — that is not a failure, and a
|
||||||
|
// request that hung waiting for it would look like one.
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "build panel request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Including "the panel killed us mid-reply", which is the successful case
|
||||||
|
// wearing a failure's clothes. The interface polls /healthz rather than
|
||||||
|
// believing either answer, so this only has to be honest about what it saw.
|
||||||
|
s.log.Warn("panel restart call did not complete", "err", err)
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"restarting": true,
|
||||||
|
"note": "the panel did not answer; it may already be restarting this container",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||||
|
writeError(w, 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,77 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"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) {
|
||||||
|
<-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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
"github.com/kristianwind/verdande/internal/mail"
|
"github.com/kristianwind/verdande/internal/mail"
|
||||||
"github.com/kristianwind/verdande/internal/mcp"
|
"github.com/kristianwind/verdande/internal/mcp"
|
||||||
@@ -39,6 +40,12 @@ type Server struct {
|
|||||||
mcp *mcp.Server
|
mcp *mcp.Server
|
||||||
updates *update.Checker
|
updates *update.Checker
|
||||||
|
|
||||||
|
// Nil when the base URL is not something an authenticator will accept — a bare
|
||||||
|
// hostname with no scheme, say. The endpoints answer 503 rather than the
|
||||||
|
// process refusing to start: passkeys are one way in among three, and an
|
||||||
|
// instance should not be unreachable because one of them cannot be offered.
|
||||||
|
passkeys *auth.WebAuthn
|
||||||
|
|
||||||
// Password guessing is the attack this application is actually exposed to, so
|
// Password guessing is the attack this application is actually exposed to, so
|
||||||
// the endpoints that check a secret are limited separately and more tightly
|
// the endpoints that check a secret are limited separately and more tightly
|
||||||
// than the rest of the API.
|
// than the rest of the API.
|
||||||
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
resetLimiter: newLimiter(5, time.Hour),
|
resetLimiter: newLimiter(5, time.Hour),
|
||||||
}
|
}
|
||||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||||
|
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||||
|
s.passkeys = w
|
||||||
|
} else {
|
||||||
|
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||||
|
}
|
||||||
s.mcp = s.buildMCP()
|
s.mcp = s.buildMCP()
|
||||||
s.updates = update.New(Version, cfg.UpdateCheck)
|
s.updates = update.New(Version, cfg.UpdateCheck)
|
||||||
|
|
||||||
@@ -132,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Method(http.MethodPost, "/password/reset",
|
r.Method(http.MethodPost, "/password/reset",
|
||||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
||||||
|
|
||||||
|
// Signing in with a passkey. Rate-limited like the password path: a
|
||||||
|
// signature cannot be guessed, but the endpoint can still be used to
|
||||||
|
// hammer the database.
|
||||||
|
r.Method(http.MethodPost, "/passkey/login/begin",
|
||||||
|
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
|
||||||
|
r.Method(http.MethodPost, "/passkey/login/finish",
|
||||||
|
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
|
||||||
|
|
||||||
// The second step of a login: a session that has passed the password
|
// The second step of a login: a session that has passed the password
|
||||||
// but not yet the code, and can reach nothing else.
|
// but not yet the code, and can reach nothing else.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -160,6 +180,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Use(s.requireSession)
|
r.Use(s.requireSession)
|
||||||
r.Get("/sessions", s.handleListSessions)
|
r.Get("/sessions", s.handleListSessions)
|
||||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||||
|
|
||||||
|
// Registering a key, and the list of them. Sessions only, for
|
||||||
|
// the same reason: a leaked token must not be able to add its
|
||||||
|
// own way in — that would turn a theft into a tenancy.
|
||||||
|
r.Get("/passkeys", s.handleListPasskeys)
|
||||||
|
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
|
||||||
|
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
|
||||||
|
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
|
||||||
|
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -231,6 +260,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
r.Get("/version", s.handleVersion)
|
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.
|
||||||
|
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.Route("/gmail", func(r chi.Router) {
|
||||||
r.Get("/", s.handleGetGmail)
|
r.Get("/", s.handleGetGmail)
|
||||||
r.Put("/", s.handleSetGmail)
|
r.Put("/", s.handleSetGmail)
|
||||||
@@ -318,6 +357,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireSession, s.requireAdmin)
|
r.Use(s.requireSession, s.requireAdmin)
|
||||||
r.Get("/errors", s.handleListErrors)
|
r.Get("/errors", s.handleListErrors)
|
||||||
|
// Restarting this instance from its own interface. The panel does
|
||||||
|
// the work — a container cannot replace its own image — and pulls
|
||||||
|
// `:latest` on the way, which is what makes a restart an update.
|
||||||
|
r.Get("/panel", s.handlePanelStatus)
|
||||||
|
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||||
// A backup file is a complete copy of the database, so this whole
|
// A backup file is a complete copy of the database, so this whole
|
||||||
// group is sessions-only as well as administrators-only: a leaked
|
// group is sessions-only as well as administrators-only: a leaked
|
||||||
// token must not be able to download everybody's data.
|
// token must not be able to download everybody's data.
|
||||||
|
|||||||
@@ -20,6 +20,89 @@ import (
|
|||||||
// away whatever letters it happens to use. A word list rather than a language
|
// 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
|
// detector: this repository ships one static binary, and a list of thirty common
|
||||||
// words catches the sentences a person would actually write.
|
// 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) {
|
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||||
// Text nodes of three characters or more, and the three attributes a person
|
// Text nodes of three characters or more, and the three attributes a person
|
||||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// 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 (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (c *Client) Since(uid uint32, limit int) ([]Message, error) {
|
||||||
|
if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||||
|
return nil, 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, fmt.Errorf("search %s: %w", c.folder, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uids := found.AllUIDs()
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return nil, 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,
|
||||||
|
BodySection: []*imap.FetchItemBodySection{{Specifier: imap.PartSpecifierText, Peek: true}},
|
||||||
|
}
|
||||||
|
msgs, err := c.c.Fetch(want, fetch).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch from %s: %w", c.folder, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Message, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Envelope == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Message{
|
||||||
|
UID: uint32(m.UID),
|
||||||
|
From: address(m.Envelope.From),
|
||||||
|
Subject: strings.TrimSpace(m.Envelope.Subject),
|
||||||
|
Snippet: snippet(m),
|
||||||
|
Date: m.Envelope.Date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, 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 line or so of the body.
|
||||||
|
//
|
||||||
|
// No MIME walk and no HTML stripping yet: this is what shows under a task's title,
|
||||||
|
// and a wrong-but-short line is a smaller problem than a parser that has to be
|
||||||
|
// right about every mail ever sent. The full mail stays where it is.
|
||||||
|
const snippetLimit = 400
|
||||||
|
|
||||||
|
func snippet(m *imapclient.FetchMessageBuffer) string {
|
||||||
|
for _, body := range m.BodySection {
|
||||||
|
text := strings.TrimSpace(string(body.Bytes))
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(text) > snippetLimit {
|
||||||
|
text = text[:snippetLimit] + "…"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -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,183 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := "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
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ type Runner struct {
|
|||||||
// startable from main.
|
// startable from main.
|
||||||
SyncGmail func(ctx context.Context, user *store.User) (int, error)
|
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)
|
||||||
|
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +65,7 @@ func (r *Runner) Start(ctx context.Context) {
|
|||||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||||
// starred and slow enough not to look like abuse.
|
// starred and slow enough not to look like abuse.
|
||||||
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
|
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
|
||||||
|
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) Wait() { r.wg.Wait() }
|
func (r *Runner) Wait() { r.wg.Wait() }
|
||||||
@@ -272,6 +277,44 @@ func (r *Runner) syncGmailAccounts(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncMailboxes polls every connected IMAP mailbox.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
userIDs, err := r.db.UsersWithMailboxes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range userIDs {
|
||||||
|
user, err := r.db.UserByID(ctx, id)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boxes, err := r.db.Mailboxes(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("list mailboxes", "err", err, "user", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range boxes {
|
||||||
|
created, err := r.SyncMailbox(ctx, user, &boxes[i])
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("mailbox sync", "err", err, "user", id, "mailbox", boxes[i].ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if created > 0 {
|
||||||
|
r.log.Info("tasks created from a mailbox", "user", id, "count", created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- housekeeping -----------------------------------------------------------------
|
// --- housekeeping -----------------------------------------------------------------
|
||||||
|
|
||||||
func (r *Runner) emptyTrash(ctx context.Context) error {
|
func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Package secret seals the values that must not travel with a backup.
|
||||||
|
//
|
||||||
|
// The reason this exists is not the database on the server — it is the copies of
|
||||||
|
// it. Backups can be downloaded through the web interface, and every one taken so
|
||||||
|
// far carried a live Gmail refresh token in plain text. Anyone who ends up with a
|
||||||
|
// copy ends up with the mailbox.
|
||||||
|
//
|
||||||
|
// So the key must not live in the database. That rules out the pattern used for
|
||||||
|
// the VAPID pair, which the instance generates and stores in the same file it
|
||||||
|
// protects: a key kept beside what it locks is not a lock. It comes from the
|
||||||
|
// environment, or from a file next to the data that the backup does not include.
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// prefix marks a sealed value, so a mixed column can be read without guessing and
|
||||||
|
// so the scheme can be replaced later without having to.
|
||||||
|
const prefix = "v1:"
|
||||||
|
|
||||||
|
// KeyFile is where the key lands when the environment does not carry one. It sits
|
||||||
|
// beside the database rather than inside it, and the backup routine must never
|
||||||
|
// learn to include it.
|
||||||
|
const KeyFile = "secret.key"
|
||||||
|
|
||||||
|
// Box seals and opens values.
|
||||||
|
type Box struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open loads the key, generating and saving one on first use.
|
||||||
|
//
|
||||||
|
// A key given in the environment wins: that is how a host keeps the secret out of
|
||||||
|
// its disks altogether. Otherwise a file, created 0600, and read back with its
|
||||||
|
// permissions checked — a key that anybody on the box can read is a key that is
|
||||||
|
// only ceremonially secret.
|
||||||
|
func Open(env, dataDir string) (*Box, error) {
|
||||||
|
if env != "" {
|
||||||
|
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(env))
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
return nil, errors.New("VERDANDE_SECRET_KEY must be 32 bytes of base64 — " +
|
||||||
|
"generate one with: openssl rand -base64 32")
|
||||||
|
}
|
||||||
|
return newBox(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dataDir, KeyFile)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
key, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
|
||||||
|
if decErr != nil || len(key) != 32 {
|
||||||
|
// Deliberately not regenerating. A new key here would not fix anything —
|
||||||
|
// it would silently make every stored token unreadable, which looks
|
||||||
|
// exactly like the mailbox having disconnected itself overnight.
|
||||||
|
return nil, fmt.Errorf("%s is not a 32-byte base64 key; move it aside only "+
|
||||||
|
"if you accept that every connected mailbox must be reconnected", path)
|
||||||
|
}
|
||||||
|
if info, statErr := os.Stat(path); statErr == nil && info.Mode().Perm()&0o077 != 0 {
|
||||||
|
return nil, fmt.Errorf("%s is readable by others (%04o); chmod 600 it", path, info.Mode().Perm())
|
||||||
|
}
|
||||||
|
return newBox(key)
|
||||||
|
|
||||||
|
case errors.Is(err, os.ErrNotExist):
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, randErr := rand.Read(key); randErr != nil {
|
||||||
|
return nil, randErr
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(key)
|
||||||
|
if writeErr := os.WriteFile(path, []byte(encoded+"\n"), 0o600); writeErr != nil {
|
||||||
|
return nil, fmt.Errorf("write %s: %w", path, writeErr)
|
||||||
|
}
|
||||||
|
return newBox(key)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBox(key []byte) (*Box, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Box{aead: aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal returns the sealed form of v. An empty string stays empty: there is nothing
|
||||||
|
// to hide, and a row full of ciphertext for "" only makes the column harder to read.
|
||||||
|
func (b *Box) Seal(v string) (string, error) {
|
||||||
|
if v == "" || IsSealed(v) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
nonce := make([]byte, b.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := b.aead.Seal(nonce, nonce, []byte(v), nil)
|
||||||
|
return prefix + base64.StdEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal returns the plain value. Anything without the marker is returned as it
|
||||||
|
// is — the column holds values written before there was a key, and they are read
|
||||||
|
// as themselves until something writes them back sealed.
|
||||||
|
func (b *Box) Unseal(v string) (string, error) {
|
||||||
|
if !IsSealed(v) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(v, prefix))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sealed value is not base64: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) < b.aead.NonceSize() {
|
||||||
|
return "", errors.New("sealed value is too short to contain a nonce")
|
||||||
|
}
|
||||||
|
nonce, body := raw[:b.aead.NonceSize()], raw[b.aead.NonceSize():]
|
||||||
|
out, err := b.aead.Open(nil, nonce, body, nil)
|
||||||
|
if err != nil {
|
||||||
|
// The overwhelmingly likely cause, and worth saying rather than "message
|
||||||
|
// authentication failed", which sends people looking for a corrupt row.
|
||||||
|
return "", errors.New("cannot read a stored secret: this is a different key " +
|
||||||
|
"than the one it was written with")
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSealed reports whether v has already been through Seal.
|
||||||
|
func IsSealed(v string) bool { return strings.HasPrefix(v, prefix) }
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAValueSurvivesTheRoundTrip(t *testing.T) {
|
||||||
|
b := mustBox(t)
|
||||||
|
for _, in := range []string{"1//0abc-refresh", "kodeord med æøå", strings.Repeat("x", 5000)} {
|
||||||
|
sealed, err := b.Seal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(sealed, in) {
|
||||||
|
t.Fatal("the sealed form still contains the value")
|
||||||
|
}
|
||||||
|
out, err := b.Unseal(sealed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != in {
|
||||||
|
t.Errorf("got %q, want %q", out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two seals of the same value must not look alike, or the column tells anyone
|
||||||
|
// reading it which accounts share a password.
|
||||||
|
func TestSealingTwiceGivesDifferentText(t *testing.T) {
|
||||||
|
b := mustBox(t)
|
||||||
|
first, _ := b.Seal("same")
|
||||||
|
second, _ := b.Seal("same")
|
||||||
|
if first == second {
|
||||||
|
t.Error("the same value sealed to the same text twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The column holds values written before there was a key. Reading must not fail
|
||||||
|
// on them, and sealing must not wrap an already-sealed value a second time.
|
||||||
|
func TestPlainTextIsReadAsItself(t *testing.T) {
|
||||||
|
b := mustBox(t)
|
||||||
|
out, err := b.Unseal("1//0abc-written-before-there-was-a-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out != "1//0abc-written-before-there-was-a-key" {
|
||||||
|
t.Errorf("an unsealed value came back as %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed, _ := b.Seal("x")
|
||||||
|
again, _ := b.Seal(sealed)
|
||||||
|
if again != sealed {
|
||||||
|
t.Error("an already-sealed value was sealed a second time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingIsStillNothing(t *testing.T) {
|
||||||
|
b := mustBox(t)
|
||||||
|
if out, _ := b.Seal(""); out != "" {
|
||||||
|
t.Errorf("an empty value sealed to %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message somebody gets after restoring a database without its key file. It
|
||||||
|
// has to point at the key, because every other symptom points at the mailbox.
|
||||||
|
func TestTheWrongKeySaysItIsTheWrongKey(t *testing.T) {
|
||||||
|
sealed, err := mustBox(t).Seal("hemmelig")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = mustBox(t).Unseal(sealed)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("another key opened it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "different key") {
|
||||||
|
t.Errorf("the error does not name the cause: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheKeyFileIsMadeOnceAndKeptPrivate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := Open("", dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, KeyFile)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Errorf("the key file is %04o, not 0600", perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening again must reuse it. A second key here would quietly make every
|
||||||
|
// stored token unreadable, and it would look like the mailboxes disconnecting
|
||||||
|
// themselves overnight.
|
||||||
|
first, _ := os.ReadFile(path)
|
||||||
|
if _, err := Open("", dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, _ := os.ReadFile(path)
|
||||||
|
if string(first) != string(second) {
|
||||||
|
t.Error("opening a second time replaced the key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKeyOthersCanReadIsRefused(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := Open("", dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, KeyFile)
|
||||||
|
if err := os.Chmod(path, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Open("", dir); err == nil {
|
||||||
|
t.Error("a world-readable key was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The environment wins, so a host can keep the key off its disks altogether.
|
||||||
|
func TestTheEnvironmentBeatsTheFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("k", 32)))
|
||||||
|
if _, err := Open(key, dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, KeyFile)); !os.IsNotExist(err) {
|
||||||
|
t.Error("a key file was written even though the environment carried one")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := Open("too-short", dir); err == nil {
|
||||||
|
t.Error("a key that is not 32 bytes was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustBox(t *testing.T) *Box {
|
||||||
|
t.Helper()
|
||||||
|
b, err := Open("", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mailbox is one connected mailbox belonging to one person.
|
||||||
|
type Mailbox struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"-"`
|
||||||
|
Kind string `json:"kind"` // "gmail" or "imap"
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"-"` // never leaves the server
|
||||||
|
Folder string `json:"folder,omitempty"`
|
||||||
|
|
||||||
|
RefreshToken string `json:"-"`
|
||||||
|
AccessToken string `json:"-"`
|
||||||
|
ExpiresAt time.Time `json:"-"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
|
||||||
|
LastUID uint32 `json:"last_uid,omitempty"`
|
||||||
|
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailboxColumns = `id, user_id, kind, name, host, username, password, folder,
|
||||||
|
refresh_token, access_token, expires_at, label, last_uid, last_sync_at, created_at`
|
||||||
|
|
||||||
|
// Mailboxes returns everything one person has connected, oldest first.
|
||||||
|
func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? ORDER BY created_at`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Mailbox
|
||||||
|
for rows.Next() {
|
||||||
|
m, err := db.scanMailbox(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailbox returns one, and only to the person it belongs to: the id is enough to
|
||||||
|
// find a row, so the owner is part of the question rather than checked after.
|
||||||
|
func (db *DB) Mailbox(ctx context.Context, userID, id string) (*Mailbox, error) {
|
||||||
|
row := db.QueryRowContext(ctx,
|
||||||
|
`SELECT `+mailboxColumns+` FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
|
m, err := db.scanMailbox(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMailbox writes one, sealing the two columns that must not travel with a
|
||||||
|
// backup. Called by hand rather than by the settings tables' own seal, which does
|
||||||
|
// not reach this table.
|
||||||
|
func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
|
||||||
|
if m.ID == "" {
|
||||||
|
m.ID = NewID()
|
||||||
|
}
|
||||||
|
if m.CreatedAt.IsZero() {
|
||||||
|
m.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
if m.Folder == "" {
|
||||||
|
m.Folder = "INBOX"
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := db.sealValue(m.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal password: %w", err)
|
||||||
|
}
|
||||||
|
refresh, err := db.sealValue(m.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal refresh_token: %w", err)
|
||||||
|
}
|
||||||
|
access, err := db.sealValue(m.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal access_token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, `
|
||||||
|
INSERT INTO mailboxes (`+mailboxColumns+`)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
host = excluded.host,
|
||||||
|
username = excluded.username,
|
||||||
|
password = excluded.password,
|
||||||
|
folder = excluded.folder,
|
||||||
|
refresh_token = excluded.refresh_token,
|
||||||
|
access_token = excluded.access_token,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
label = excluded.label,
|
||||||
|
last_uid = excluded.last_uid,
|
||||||
|
last_sync_at = excluded.last_sync_at`,
|
||||||
|
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
|
||||||
|
refresh, access, unixOrZero(m.ExpiresAt), m.Label,
|
||||||
|
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkMailboxRead records how far a run got. Written separately from the rest so
|
||||||
|
// a sync never rewrites the credentials it is holding in memory.
|
||||||
|
func (db *DB) MarkMailboxRead(ctx context.Context, id string, lastUID uint32, at time.Time) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE mailboxes SET last_uid = ?, last_sync_at = ? WHERE id = ?`,
|
||||||
|
lastUID, at.Unix(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMailbox disconnects one. The tasks it made stay: they are the person's
|
||||||
|
// work now, not the mailbox's.
|
||||||
|
func (db *DB) DeleteMailbox(ctx context.Context, userID, id string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersWithMailboxes returns everybody the sweep has to visit.
|
||||||
|
func (db *DB) UsersWithMailboxes(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM mailboxes`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
|
||||||
|
var m Mailbox
|
||||||
|
var expires, lastSync, created int64
|
||||||
|
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
|
||||||
|
&m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires,
|
||||||
|
&m.Label, &m.LastUID, &lastSync, &created)
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range []*string{&m.Password, &m.RefreshToken, &m.AccessToken} {
|
||||||
|
plain, err := db.unsealValue(*field)
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
*field = plain
|
||||||
|
}
|
||||||
|
|
||||||
|
m.ExpiresAt = timeOrZero(expires)
|
||||||
|
m.LastSyncAt = timeOrZero(lastSync)
|
||||||
|
m.CreatedAt = time.Unix(created, 0)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sealValue and unsealValue are the single-string versions of the map helpers in
|
||||||
|
// notifications.go. Without a key they pass the value through, which is what the
|
||||||
|
// tests that never set one expect.
|
||||||
|
func (db *DB) sealValue(v string) (string, error) {
|
||||||
|
if db.box == nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return db.box.Seal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) unsealValue(v string) (string, error) {
|
||||||
|
if db.box == nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return db.box.Unseal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixOrZero(t time.Time) int64 {
|
||||||
|
if t.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeOrZero(unix int64) time.Time {
|
||||||
|
if unix == 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return time.Unix(unix, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const password = "abcd-efgh-ijkl-mnop"
|
||||||
|
m := &Mailbox{
|
||||||
|
UserID: userID,
|
||||||
|
Kind: "imap",
|
||||||
|
Name: "iCloud",
|
||||||
|
Host: "imap.mail.me.com:993",
|
||||||
|
Username: "[email protected]",
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT password FROM mailboxes WHERE id = ?`, m.ID).Scan(&stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(stored, password) {
|
||||||
|
t.Fatalf("the password is in the row as typed: %s", stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
back, err := db.Mailbox(ctx, userID, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back == nil {
|
||||||
|
t.Fatal("the mailbox was not found")
|
||||||
|
}
|
||||||
|
if back.Password != password {
|
||||||
|
t.Errorf("got password %q", back.Password)
|
||||||
|
}
|
||||||
|
if back.Folder != "INBOX" {
|
||||||
|
t.Errorf("the folder defaulted to %q, want INBOX", back.Folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The question that prompted the table: two people on one instance, each with
|
||||||
|
// their own mailbox, neither seeing the other's.
|
||||||
|
func TestAMailboxBelongsToOnePerson(t *testing.T) {
|
||||||
|
db, kristian := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
andreas := &User{Email: "[email protected]", Name: "Andreas"}
|
||||||
|
if err := db.CreateUser(ctx, andreas, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mine := &Mailbox{UserID: kristian, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||||
|
theirs := &Mailbox{UserID: andreas.ID, Kind: "imap", Host: "imap.fastmail.com:993", Username: "andreas"}
|
||||||
|
for _, m := range []*Mailbox{mine, theirs} {
|
||||||
|
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := db.Mailboxes(ctx, kristian)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Username != "kw" {
|
||||||
|
t.Fatalf("got %d mailboxes for Kristian, want only his own", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the id alone is not enough to reach somebody else's.
|
||||||
|
other, err := db.Mailbox(ctx, kristian, theirs.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if other != nil {
|
||||||
|
t.Error("one person read another's mailbox by its id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several mailboxes each is the ordinary case — a work Gmail and a private
|
||||||
|
// iCloud — and is the whole reason this is a table rather than a setting.
|
||||||
|
func TestOnePersonCanHaveSeveral(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, host := range []string{"imap.mail.me.com:993", "imap.fastmail.com:993"} {
|
||||||
|
if err := db.SaveMailbox(ctx, &Mailbox{
|
||||||
|
UserID: userID, Kind: "imap", Host: host, Username: "kw",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list, err := db.Mailboxes(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("got %d mailboxes, want 2", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same mailbox twice would make two tasks out of every mail.
|
||||||
|
func TestTheSameMailboxTwiceIsRefused(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||||
|
if err := db.SaveMailbox(ctx, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||||
|
if err := db.SaveMailbox(ctx, again); err == nil {
|
||||||
|
t.Error("the same host and username was accepted a second time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadingProgressIsRemembered(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||||
|
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
at := time.Now().Truncate(time.Second)
|
||||||
|
if err := db.MarkMailboxRead(ctx, m.ID, 4711, at); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
back, err := db.Mailbox(ctx, userID, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.LastUID != 4711 {
|
||||||
|
t.Errorf("last uid is %d", back.LastUID)
|
||||||
|
}
|
||||||
|
if !back.LastSyncAt.Equal(at) {
|
||||||
|
t.Errorf("last sync is %v, want %v", back.LastSyncAt, at)
|
||||||
|
}
|
||||||
|
// The credentials it was holding must not have been rewritten as a side effect.
|
||||||
|
if back.Host != "imap.mail.me.com:993" {
|
||||||
|
t.Errorf("the host changed to %q", back.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnecting takes the mailbox, not the work it produced.
|
||||||
|
func TestDisconnectingKeepsTheTasks(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
|
||||||
|
if err := db.SaveMailbox(ctx, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.DeleteMailbox(ctx, userID, m.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := db.Mailboxes(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Errorf("the mailbox is still connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Passkeys: a key on a device instead of a secret in a head.
|
||||||
|
--
|
||||||
|
-- The account already has two ways in — a password, and a TOTP code on top of it.
|
||||||
|
-- Both are shared secrets: the server keeps something that can be stolen from it,
|
||||||
|
-- and the person keeps something that can be phished out of them. A passkey is
|
||||||
|
-- neither. The private half never leaves the device, the public half is all this
|
||||||
|
-- table holds, and the signature is bound to the origin — so a convincing copy of
|
||||||
|
-- the sign-in page at a different address gets nothing.
|
||||||
|
--
|
||||||
|
-- One row per credential rather than per account, because a person has more than
|
||||||
|
-- one device and losing a laptop should not mean losing the way in. The name is
|
||||||
|
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
|
||||||
|
-- list nobody can read is a list nobody revokes from.
|
||||||
|
--
|
||||||
|
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
|
||||||
|
-- a counter that goes backwards means the credential has been cloned, which is
|
||||||
|
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
|
||||||
|
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
|
||||||
|
CREATE TABLE passkeys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
-- The credential id the authenticator generated, raw bytes, base64url as
|
||||||
|
-- stored. Unique across the instance: it is what a login is looked up by,
|
||||||
|
-- before anybody has said who they are.
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
public_key BLOB NOT NULL,
|
||||||
|
-- The AAGUID identifies the authenticator model, which is what lets a list say
|
||||||
|
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
|
||||||
|
aaguid TEXT NOT NULL DEFAULT '',
|
||||||
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Whether the credential lives on the device itself (a resident key). Only
|
||||||
|
-- those can start a login with no email typed first, so the interface has to
|
||||||
|
-- know which it has before it offers that.
|
||||||
|
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
|
||||||
|
-- face. A key that only proves possession is a first factor; one that verified
|
||||||
|
-- its owner is both, and that is what decides whether a password is still
|
||||||
|
-- asked for.
|
||||||
|
user_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
|
||||||
|
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
|
||||||
|
|
||||||
|
-- The challenge a registration or a login is answered against.
|
||||||
|
--
|
||||||
|
-- Kept server-side rather than in a cookie: the whole point of the challenge is
|
||||||
|
-- that the server chose it and remembers choosing it. One row per attempt, deleted
|
||||||
|
-- when it is answered, and swept when it expires — a challenge that outlives its
|
||||||
|
-- ceremony is a replay waiting to be tried.
|
||||||
|
--
|
||||||
|
-- user_id is null for a login that has not said who it is yet, which is the case
|
||||||
|
-- a discoverable credential exists for.
|
||||||
|
CREATE TABLE webauthn_challenges (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
challenge TEXT NOT NULL,
|
||||||
|
-- 'register' or 'login'. A challenge issued for one must not be answerable by
|
||||||
|
-- the other: registering is done by somebody already signed in, and logging in
|
||||||
|
-- is not.
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
-- The library's own session state, which carries the allowed credentials and
|
||||||
|
-- the verification requirement it will check the answer against.
|
||||||
|
session BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
CHECK (purpose IN ('register', 'login'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Mailboxes: one row per connected mailbox, not one setting per person.
|
||||||
|
--
|
||||||
|
-- Gmail lived in `user_settings` under scope 'gmail'. That table has `user_id` as
|
||||||
|
-- its whole primary key, so it holds exactly one scope per person and its upsert
|
||||||
|
-- rewrites the scope name on conflict — adding IMAP beside Gmail there would have
|
||||||
|
-- deleted the Gmail connection without saying so. Migration 0011 warns about this
|
||||||
|
-- in as many words, and the warning was nearly walked into a second time.
|
||||||
|
--
|
||||||
|
-- The shape is wrong for the thing anyway. A person has mailboxes, plural: a work
|
||||||
|
-- Gmail and a private iCloud is the ordinary case, not the exotic one. A row each
|
||||||
|
-- says that. It also answers the question that prompted this — every mailbox
|
||||||
|
-- belongs to a user, so two people on the same instance connect their own and see
|
||||||
|
-- only their own. The one piece that stays instance-wide is the Google OAuth
|
||||||
|
-- client, which is a single registration everybody signs in through, and that is
|
||||||
|
-- correct: it is the app, not the account.
|
||||||
|
--
|
||||||
|
-- `kind` rather than two tables. What differs is how the credential is obtained —
|
||||||
|
-- OAuth for Gmail, a password for IMAP — and what differs is a nullable column,
|
||||||
|
-- not a schema. What is the same is everything the sync loop touches: which
|
||||||
|
-- mailbox, how far it has read, when it last ran.
|
||||||
|
--
|
||||||
|
-- `password` and `refresh_token` are written sealed; see internal/secret. The
|
||||||
|
-- store's own seal/unseal only covers the settings tables, so this table's methods
|
||||||
|
-- call them by hand — a fact worth knowing before adding a third secret column.
|
||||||
|
--
|
||||||
|
-- `last_uid` is IMAP's place-marker and means nothing for Gmail, which keeps its
|
||||||
|
-- own by query. Zero is "nothing read yet" for both.
|
||||||
|
CREATE TABLE mailboxes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('gmail', 'imap')),
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
-- IMAP
|
||||||
|
host TEXT NOT NULL DEFAULT '',
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
folder TEXT NOT NULL DEFAULT 'INBOX',
|
||||||
|
|
||||||
|
-- Gmail
|
||||||
|
refresh_token TEXT NOT NULL DEFAULT '',
|
||||||
|
access_token TEXT NOT NULL DEFAULT '',
|
||||||
|
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
last_uid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The sweep asks "whose mailboxes are due", so it reads by user.
|
||||||
|
CREATE INDEX mailboxes_by_user ON mailboxes (user_id);
|
||||||
|
|
||||||
|
-- The same mailbox twice is a mistake, not a choice: two rows would make two
|
||||||
|
-- tasks out of every mail. Gmail's uniqueness is the account, IMAP's is the host
|
||||||
|
-- and the username together, and `kind` keeps the two from colliding.
|
||||||
|
CREATE UNIQUE INDEX mailboxes_once ON mailboxes (user_id, kind, host, username);
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
@@ -313,6 +314,65 @@ func (db *DB) UsersWithGmail(ctx context.Context) ([]User, error) {
|
|||||||
|
|
||||||
// UserSettings reads one scope of a person's integration settings. A missing row is
|
// UserSettings reads one scope of a person's integration settings. A missing row is
|
||||||
// an empty map, not an error: nobody has settings until they set some.
|
// an empty map, not an error: nobody has settings until they set some.
|
||||||
|
// --- sealed values ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// secretFields are the keys whose values never leave this process in the clear.
|
||||||
|
//
|
||||||
|
// A named set rather than "seal everything": the rest of a settings blob is what
|
||||||
|
// somebody reads when a mailbox misbehaves — which folder, which label, which
|
||||||
|
// address — and turning all of it into ciphertext would buy nothing and cost the
|
||||||
|
// ability to look.
|
||||||
|
var secretFields = map[string]bool{
|
||||||
|
"refresh_token": true,
|
||||||
|
"access_token": true,
|
||||||
|
"password": true,
|
||||||
|
"client_secret": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// seal returns a copy of values with the secret fields sealed. Without a key it
|
||||||
|
// returns them untouched, which is what the tests that never set one expect.
|
||||||
|
func (db *DB) seal(values map[string]any) (map[string]any, error) {
|
||||||
|
if db.box == nil {
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(values))
|
||||||
|
for k, v := range values {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !secretFields[k] || !ok {
|
||||||
|
out[k] = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sealed, err := db.box.Seal(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("seal %s: %w", k, err)
|
||||||
|
}
|
||||||
|
out[k] = sealed
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unseal is seal's other direction. Values written before there was a key come
|
||||||
|
// back as themselves; the next write puts them away properly, so the column
|
||||||
|
// converts itself as it is used rather than in one migration that has to be right
|
||||||
|
// about every row at once.
|
||||||
|
func (db *DB) unseal(values map[string]any) (map[string]any, error) {
|
||||||
|
if db.box == nil {
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
for k, v := range values {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !secretFields[k] || !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plain, err := db.box.Unseal(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", k, err)
|
||||||
|
}
|
||||||
|
values[k] = plain
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) {
|
func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) {
|
||||||
var raw string
|
var raw string
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
@@ -329,10 +389,14 @@ func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[strin
|
|||||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||||
return map[string]any{}, nil
|
return map[string]any{}, nil
|
||||||
}
|
}
|
||||||
return values, nil
|
return db.unseal(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error {
|
func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error {
|
||||||
|
values, err := db.seal(values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
raw, err := json.Marshal(values)
|
raw, err := json.Marshal(values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -370,10 +434,14 @@ func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]an
|
|||||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||||
return map[string]any{}, nil
|
return map[string]any{}, nil
|
||||||
}
|
}
|
||||||
return values, nil
|
return db.unseal(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
|
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
|
||||||
|
values, err := db.seal(values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
raw, err := json.Marshal(values)
|
raw, err := json.Marshal(values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Passkey is one credential on one device.
|
||||||
|
//
|
||||||
|
// One row per credential rather than per account: a person has a laptop and a
|
||||||
|
// phone, and losing one must not mean losing the way in. The name is theirs to
|
||||||
|
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
|
||||||
|
// read is a list nobody revokes from.
|
||||||
|
type Passkey struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
CredentialID string
|
||||||
|
PublicKey []byte
|
||||||
|
AAGUID string
|
||||||
|
SignCount uint32
|
||||||
|
Discoverable bool
|
||||||
|
UserVerified bool
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastUsedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
|
||||||
|
if p.ID == "" {
|
||||||
|
p.ID = NewID()
|
||||||
|
}
|
||||||
|
p.CreatedAt = time.Now().UTC()
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||||
|
discoverable, user_verified, name, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
|
||||||
|
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||||
|
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||||
|
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanPasskeys(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyByCredentialID is how a login finds the account, before anybody has said
|
||||||
|
// who they are. The credential id is unique across the instance, which is what
|
||||||
|
// makes that possible.
|
||||||
|
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||||
|
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||||
|
FROM passkeys WHERE credential_id = ?`, credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
list, err := scanPasskeys(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return &list[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchPasskey records a use and the authenticator's counter.
|
||||||
|
//
|
||||||
|
// The counter is stored to be compared. One that goes backwards means the
|
||||||
|
// credential has been cloned, which is the single thing this design cannot
|
||||||
|
// otherwise detect — and not every authenticator keeps one, so zero means "not
|
||||||
|
// offered" rather than "suspicious".
|
||||||
|
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
|
||||||
|
signCount, time.Now().Unix(), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
|
||||||
|
// id read in one step and deleted in the next is a window, and the reason somebody
|
||||||
|
// is on this screen at all is usually that they think a device is gone.
|
||||||
|
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPasskeys is what stops somebody removing their last way in when it is the
|
||||||
|
// only one they have.
|
||||||
|
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
|
||||||
|
out := []Passkey{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Passkey
|
||||||
|
var discoverable, verified int
|
||||||
|
var created, used int64
|
||||||
|
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
|
||||||
|
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Discoverable = discoverable == 1
|
||||||
|
p.UserVerified = verified == 1
|
||||||
|
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
if used > 0 {
|
||||||
|
p.LastUsedAt = time.Unix(used, 0).UTC()
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- challenges -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// StoreChallenge keeps the server's side of one ceremony.
|
||||||
|
//
|
||||||
|
// Server-side rather than in a cookie: the whole point of a challenge is that the
|
||||||
|
// server chose it and remembers choosing it. It is deleted when answered and swept
|
||||||
|
// when it expires — a challenge that outlives its ceremony is a replay waiting to
|
||||||
|
// be tried.
|
||||||
|
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
|
||||||
|
id := NewID()
|
||||||
|
now := time.Now()
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeChallenge reads a challenge and removes it in the same transaction.
|
||||||
|
//
|
||||||
|
// Taken rather than read, because a challenge is answerable exactly once. Two
|
||||||
|
// requests racing the same one would otherwise both succeed, which is the whole
|
||||||
|
// shape of a replay.
|
||||||
|
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var owner sql.NullString
|
||||||
|
var expires int64
|
||||||
|
row := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
|
||||||
|
id, purpose)
|
||||||
|
if err := row.Scan(&owner, &session, &expires); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if time.Now().Unix() > expires {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
userID = owner.String
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return userID, session, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
|
||||||
|
// itself; an abandoned one is what this is for.
|
||||||
|
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The whole point of the exercise: what is written must not be readable in the
|
||||||
|
// file that a backup copies.
|
||||||
|
func TestATokenIsNotStoredInTheClear(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const token = "1//0-a-real-looking-refresh-token"
|
||||||
|
if err := db.SetUserSettings(ctx, userID, "gmail", map[string]any{
|
||||||
|
"refresh_token": token,
|
||||||
|
"label": "Verdande",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, token) {
|
||||||
|
t.Fatalf("the token is in the row as written: %s", raw)
|
||||||
|
}
|
||||||
|
// The rest stays readable, which is the reason for naming the secret fields
|
||||||
|
// rather than sealing the whole blob.
|
||||||
|
if !strings.Contains(raw, "Verdande") {
|
||||||
|
t.Errorf("the label was sealed too: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
back, err := db.UserSettings(ctx, userID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back["refresh_token"] != token {
|
||||||
|
t.Errorf("got %q back", back["refresh_token"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The migration that matters. A row written before there was a key must still be
|
||||||
|
// readable, and must put itself away sealed the next time it is written — nobody
|
||||||
|
// should have to reconnect a mailbox because the server learned to encrypt.
|
||||||
|
func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const token = "1//0-written-when-there-was-no-key"
|
||||||
|
plain, _ := json.Marshal(map[string]any{"refresh_token": token})
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
|
VALUES (?, 'gmail', ?, 0)`, userID, string(plain)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := db.UserSettings(ctx, userID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a row from before the key could not be read: %v", err)
|
||||||
|
}
|
||||||
|
if values["refresh_token"] != token {
|
||||||
|
t.Fatalf("got %q, want the plain token back", values["refresh_token"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written back — and now sealed, without anything having asked for it.
|
||||||
|
if err := db.SetUserSettings(ctx, userID, "gmail", values); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, token) {
|
||||||
|
t.Error("the row is still in the clear after being written back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersWithGmail finds people by the presence of the key, not its value, so it
|
||||||
|
// must keep working now that the value is ciphertext.
|
||||||
|
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.SetUserSettings(ctx, userID, "gmail",
|
||||||
|
map[string]any{"refresh_token": "1//0-token"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
users, err := db.UsersWithGmail(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(users) != 1 {
|
||||||
|
t.Fatalf("the sweep found %d connected mailboxes, want 1", len(users))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sealedStore(t *testing.T) (*DB, string) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
box, err := secret.Open("", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db.UseSecrets(box)
|
||||||
|
|
||||||
|
user := &User{Email: "[email protected]", Name: "Kristian"}
|
||||||
|
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db, user.ID
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/kristianwind/verdande/internal/secret"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -23,8 +24,20 @@ var migrationFS embed.FS
|
|||||||
type DB struct {
|
type DB struct {
|
||||||
*sql.DB
|
*sql.DB
|
||||||
path string
|
path string
|
||||||
|
|
||||||
|
// box seals the values that must not travel with a backup. Nil in the tests
|
||||||
|
// that do not care, where a secret is stored as itself — see Seal.
|
||||||
|
box *secret.Box
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UseSecrets gives the store the key it seals with. Called once at startup, before
|
||||||
|
// anything reads or writes settings.
|
||||||
|
//
|
||||||
|
// Separate from Open because the key comes from the environment or from a file
|
||||||
|
// beside the data, and a database that cannot be opened without it would make a
|
||||||
|
// misplaced key look like a corrupt disk.
|
||||||
|
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
||||||
|
|
||||||
// dsn is the connection string.
|
// dsn is the connection string.
|
||||||
//
|
//
|
||||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ gameskill:
|
|||||||
invites and reminders, then start it and open the URL to create the first
|
invites and reminders, then start it and open the URL to create the first
|
||||||
admin account.
|
admin account.
|
||||||
author: "kristianwind"
|
author: "kristianwind"
|
||||||
version: 2
|
# Bumped whenever the variables below change. The panel keys its catalogue on
|
||||||
|
# this: a manifest with new fields and an unchanged version is a manifest the
|
||||||
|
# panel has no reason to re-read, so the fields never appear and the failure is
|
||||||
|
# silent — the server's settings page simply does not offer them.
|
||||||
|
version: 4
|
||||||
icon: "app"
|
icon: "app"
|
||||||
|
|
||||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||||
@@ -78,6 +82,29 @@ gameskill:
|
|||||||
type: bool
|
type: bool
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
# Restarting from verdande's own settings, 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. All three are needed together; the page names
|
||||||
|
# whichever are missing.
|
||||||
|
- key: VERDANDE_PANEL_URL
|
||||||
|
name: "Panel address, so verdande can ask to be restarted from its own settings"
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
- key: VERDANDE_PANEL_TOKEN
|
||||||
|
name: "Panel API token — from an account with control of this server"
|
||||||
|
type: string
|
||||||
|
secret: true
|
||||||
|
default: ""
|
||||||
|
- key: VERDANDE_PANEL_SERVER_ID
|
||||||
|
name: "This server's id in the panel (the one in its URL)"
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
- key: VERDANDE_SECRET_KEY
|
||||||
|
name: "Key for the tokens and mailbox passwords in the database — 32 bytes of base64, from: openssl rand -base64 32. Leave empty to keep one in a file beside the data. Change it and every mailbox must be connected again."
|
||||||
|
type: string
|
||||||
|
secret: true
|
||||||
|
default: ""
|
||||||
|
|
||||||
install:
|
install:
|
||||||
# The image has no shell, so the install step borrows one. It only has to make
|
# The image has no shell, so the install step borrows one. It only has to make
|
||||||
# the directory layout exist; verdande creates and migrates its own database on
|
# the directory layout exist; verdande creates and migrates its own database on
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
@@ -50,9 +50,16 @@ function watchForTrouble(page) {
|
|||||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||||
page.on('response', (response) => {
|
page.on('response', (response) => {
|
||||||
const url = response.url();
|
const url = response.url();
|
||||||
if (url.includes('/api/') && !response.ok()) {
|
if (!url.includes('/api/') || response.ok()) return;
|
||||||
trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
|
|
||||||
}
|
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||||
|
// which is the correct answer on the sign-in page and after signing out.
|
||||||
|
// Every other non-ok response is worth failing over — that is what this
|
||||||
|
// watcher is for.
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||||
|
|
||||||
|
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||||
});
|
});
|
||||||
return trouble;
|
return trouble;
|
||||||
}
|
}
|
||||||
@@ -295,26 +302,41 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||||
|
|
||||||
// The words in the heading must not sit on top of each other.
|
// A group's name must be readable in full, and must start where a project's
|
||||||
|
// name starts.
|
||||||
//
|
//
|
||||||
// Everything else here clicks by accessible name, which is exactly why this
|
// Both halves are here because both were once broken at the same time and for
|
||||||
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
|
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
||||||
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
|
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
||||||
// Both buttons still worked, so every assertion in this file passed.
|
// sat before the name, pushing a group 22px further in than the projects it
|
||||||
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
|
// contains — so a group read as if it were filed under them. Every assertion
|
||||||
// Links as well as buttons: the group's name is a link to its page now, and
|
// in this file passed through both, because both buttons still worked.
|
||||||
// a measurement that skipped it would miss the widest thing in the row.
|
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
||||||
[...el.querySelectorAll('button, a')].map((b) => {
|
const link = el.querySelector('h2 a');
|
||||||
const r = b.getBoundingClientRect();
|
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
||||||
return { text: b.textContent.trim(), left: r.left, right: r.right };
|
// The last child of the link is the name itself; the dot is a span before it.
|
||||||
})
|
return {
|
||||||
);
|
clipped: link.scrollWidth > link.clientWidth + 1,
|
||||||
for (let i = 1; i < boxes.length; i++) {
|
left: name.left,
|
||||||
expect(
|
right: link.getBoundingClientRect().right,
|
||||||
boxes[i].left,
|
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
|
||||||
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
|
};
|
||||||
).toBeGreaterThanOrEqual(boxes[i - 1].right);
|
});
|
||||||
}
|
|
||||||
|
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
||||||
|
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
||||||
|
|
||||||
|
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
||||||
|
// mark, and it is the only thing in them that means the same on both sides.
|
||||||
|
// Comparing the group's inner link with the project's outer box reported the
|
||||||
|
// row's own padding as a misalignment.
|
||||||
|
const projectLeft = await sidebar
|
||||||
|
.getByRole('link', { name: 'Regnskab' })
|
||||||
|
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
||||||
|
expect(
|
||||||
|
Math.abs(shape.left - projectLeft),
|
||||||
|
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
||||||
|
).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
// Dragged in, then dragged back out onto "Projekter" — which is the reason
|
||||||
// that heading is a drop target at all: with every project filed away there
|
// that heading is a drop target at all: with every project filed away there
|
||||||
@@ -341,7 +363,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||||
// edits to the same thing, and a heading carrying four controls crowded the
|
// edits to the same thing, and a heading carrying four controls crowded the
|
||||||
// name it exists to show.
|
// name it exists to show.
|
||||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
// Double-click on the row opens it; the two words that used to sit on top of
|
||||||
|
// the name are gone from the sidebar entirely.
|
||||||
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||||
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
||||||
// Still open: picking a colour must not close the form under the pointer, which
|
// Still open: picking a colour must not close the form under the pointer, which
|
||||||
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
||||||
@@ -385,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
|
|
||||||
// Renaming writes one row rather than one per project, which is the reason a
|
// Renaming writes one row rather than one per project, which is the reason a
|
||||||
// group is a table and not a string repeated on every project.
|
// group is a table and not a string repeated on every project.
|
||||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
||||||
const name = sidebar.getByLabel('Gruppens navn');
|
const name = sidebar.getByLabel('Gruppens navn');
|
||||||
await name.fill('Kontoret');
|
await name.fill('Kontoret');
|
||||||
await name.press('Enter');
|
await name.press('Enter');
|
||||||
@@ -394,8 +418,11 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
// Deleting the heading must not take the project filed under it. It is
|
// Deleting the heading must not take the project filed under it. It is
|
||||||
// ungrouped here, which is the case that would look identical either way if
|
// ungrouped here, which is the case that would look identical either way if
|
||||||
// the assertion were only "the group is gone".
|
// the assertion were only "the group is gone".
|
||||||
|
// Deleting is on the group's own page now, where the projects it will not take
|
||||||
|
// with it are on the screen above the button.
|
||||||
|
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
await sidebar.getByRole('button', { name: 'Slet' }).click();
|
await page.getByRole('button', { name: 'Slet' }).click();
|
||||||
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
||||||
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||||
|
|
||||||
@@ -577,7 +604,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
|||||||
await invitee.goto(link);
|
await invitee.goto(link);
|
||||||
|
|
||||||
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
|
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
|
||||||
await invitee.getByLabel('Navn').fill('Nabo');
|
await invitee.getByLabel('Navn', { exact: true }).fill('Nabo');
|
||||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
|
||||||
@@ -734,7 +761,7 @@ test('en administrator kan invitere en bruger til instansen', async ({ browser,
|
|||||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
const invitee = await context.newPage();
|
const invitee = await context.newPage();
|
||||||
await invitee.goto(link);
|
await invitee.goto(link);
|
||||||
await invitee.getByLabel('Navn').fill('Anders');
|
await invitee.getByLabel('Navn', { exact: true }).fill('Anders');
|
||||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
|
|
||||||
@@ -807,7 +834,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
const andreas = await context.newPage();
|
const andreas = await context.newPage();
|
||||||
await andreas.goto(link);
|
await andreas.goto(link);
|
||||||
await andreas.getByLabel('Navn').fill('andreas');
|
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
||||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
@@ -964,7 +991,7 @@ test('historik-siden viser hændelser fra et projekt, administratoren ikke er me
|
|||||||
expect(link).toContain('/invite?token=');
|
expect(link).toContain('/invite?token=');
|
||||||
|
|
||||||
await theirs.goto(link);
|
await theirs.goto(link);
|
||||||
await theirs.getByLabel('Navn').fill('Sigrid');
|
await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid');
|
||||||
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
await theirs.getByRole('button', { name: 'Opret konto' }).click();
|
await theirs.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
@@ -1150,8 +1177,13 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) =
|
|||||||
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
||||||
);
|
);
|
||||||
|
|
||||||
// The Inbox keeps its name: it is a project row created at signup, not a
|
// The Inbox is named by the server at account creation, before anybody has seen
|
||||||
// string in the interface, and the person can rename it like any other.
|
// a settings page — so it is the one project name nobody chose, and it follows
|
||||||
|
// the language like a label. Rename it and that becomes your name for it in
|
||||||
|
// every language, the same rule as any other project.
|
||||||
|
await expect(
|
||||||
|
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Danish again, for everything that runs after this.
|
// Danish again, for everything that runs after this.
|
||||||
await page.goto('/indstillinger');
|
await page.goto('/indstillinger');
|
||||||
@@ -1447,6 +1479,97 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A passkey, registered and then signed in with, end to end.
|
||||||
|
*
|
||||||
|
* The Go tests cover who may reach these endpoints and that a challenge is spent
|
||||||
|
* when it is used. What they cannot do is produce a signature — that needs an
|
||||||
|
* authenticator. Chrome has a virtual one behind CDP, and it is the only way to
|
||||||
|
* find out whether the encoding is right: every boundary here is a base64url
|
||||||
|
* conversion, and one wrong byte produces a signature that will not verify,
|
||||||
|
* reported as "that key was not accepted" — which reads as a broken key rather
|
||||||
|
* than a broken encoder.
|
||||||
|
*
|
||||||
|
* Chromium only, and deliberately so: this is testing our ceremony, not WebKit's
|
||||||
|
* authenticator.
|
||||||
|
*/
|
||||||
|
test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => {
|
||||||
|
test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium');
|
||||||
|
|
||||||
|
// Its own context, at `localhost` rather than the loopback address the rest of
|
||||||
|
// the suite uses. WebAuthn refuses an IP as a relying party id — the browser
|
||||||
|
// throws `SecurityError: This is an invalid domain` — and localhost is the
|
||||||
|
// exception every browser makes. It is not the shared baseURL because
|
||||||
|
// resolving `localhost` is slow enough here to cost the whole suite minutes.
|
||||||
|
//
|
||||||
|
// Cookies are bound to an origin, so the signed-in state from the setup project
|
||||||
|
// does not carry across: this signs in with a password first, which is also the
|
||||||
|
// honest order — you register a key on an account you have already proved you
|
||||||
|
// own.
|
||||||
|
const origin = 'http://localhost:8097';
|
||||||
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
await page.goto(`${origin}/`);
|
||||||
|
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||||
|
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||||
|
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
|
||||||
|
// A virtual authenticator that verifies its owner, so the key counts as both
|
||||||
|
// factors — which is what lets the login complete without a password.
|
||||||
|
const client = await page.context().newCDPSession(page);
|
||||||
|
await client.send('WebAuthn.enable');
|
||||||
|
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
|
||||||
|
options: {
|
||||||
|
protocol: 'ctap2',
|
||||||
|
transport: 'internal',
|
||||||
|
hasResidentKey: true,
|
||||||
|
hasUserVerification: true,
|
||||||
|
isUserVerified: true,
|
||||||
|
automaticPresenceSimulation: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${origin}/indstillinger`);
|
||||||
|
await page.getByLabel('Navn på nøglen').fill('Testnøglen');
|
||||||
|
await page.getByRole('button', { name: 'Tilføj en nøgle' }).click();
|
||||||
|
|
||||||
|
// It is in the list, and it counts as both factors — the virtual authenticator
|
||||||
|
// verifies, so the login below needs no password.
|
||||||
|
const key = page.locator('li').filter({ hasText: 'Testnøglen' });
|
||||||
|
await expect(key).toBeVisible();
|
||||||
|
await expect(key.getByText('begge faktorer')).toBeVisible();
|
||||||
|
|
||||||
|
// It survives a reload, so this is the server's row and not the page's state.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible();
|
||||||
|
|
||||||
|
// Now sign out and back in with it. No email typed: the device knows which
|
||||||
|
// account its key belongs to, which is also why this page cannot be used to
|
||||||
|
// find out who has an account here.
|
||||||
|
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
||||||
|
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
||||||
|
// Playwright matches an accessible name loosely unless told otherwise.
|
||||||
|
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// And the key records that it was used, which is what makes the list worth
|
||||||
|
// reading when somebody wonders whether a device is still in play.
|
||||||
|
await page.goto(`${origin}/indstillinger`);
|
||||||
|
await expect(
|
||||||
|
page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
||||||
|
await context.close();
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sidebar never scrolls sideways.
|
* The sidebar never scrolls sideways.
|
||||||
*
|
*
|
||||||
@@ -1465,7 +1588,9 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|||||||
await page.goto('/indstillinger');
|
await page.goto('/indstillinger');
|
||||||
|
|
||||||
// A name long enough to overflow the column at its default width.
|
// A name long enough to overflow the column at its default width.
|
||||||
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
|
// `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
|
||||||
|
// ambiguous locator waits for one of them to go away rather than failing.
|
||||||
|
await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
|
||||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||||
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
||||||
|
|
||||||
@@ -1480,7 +1605,7 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|||||||
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
// Put the name back, so the tests after this one see the account they expect.
|
// Put the name back, so the tests after this one see the account they expect.
|
||||||
await page.getByLabel('Navn').fill(USER.name);
|
await page.getByLabel('Navn', { exact: true }).fill(USER.name);
|
||||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
@@ -1535,6 +1660,16 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
|
|||||||
// inside a board was exactly true.
|
// inside a board was exactly true.
|
||||||
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
|
||||||
|
|
||||||
|
// A board gets the width a reading column withholds. This was `.view:has(.board)`
|
||||||
|
// for as long as the board has existed and never once matched: `.board` is inside
|
||||||
|
// a child component, and Svelte scopes a selector to the component that wrote it.
|
||||||
|
// The rule read correctly, compiled, and could not fire.
|
||||||
|
const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
||||||
|
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||||
|
const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
||||||
|
expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow);
|
||||||
|
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||||
const field = page.getByLabel('Ny sektion');
|
const field = page.getByLabel('Ny sektion');
|
||||||
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
|
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
|
||||||
@@ -1633,3 +1768,81 @@ test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
|
|||||||
expect(response.headers()['content-type']).toContain('application/json');
|
expect(response.headers()['content-type']).toContain('application/json');
|
||||||
expect((await response.json()).code).toBe('unauthorized');
|
expect((await response.json()).code).toBe('unauthorized');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('man kan skrive hvor som helst, og feltet fortæller hvad det forstår', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Nothing focused, nothing clicked: just type. The thought does not have to
|
||||||
|
// survive a journey to find somewhere to put it.
|
||||||
|
await page.locator('body').click({ position: { x: 5, y: 5 } });
|
||||||
|
// "i dag" because this is the Today view: a task parsed onto no date at all is
|
||||||
|
// correctly absent from it, which would read as the capture having failed.
|
||||||
|
await page.keyboard.type('ring til Anders i dag');
|
||||||
|
|
||||||
|
const field = page.locator('[data-quickadd]');
|
||||||
|
await expect(field).toBeFocused();
|
||||||
|
// Every letter arrives, including the first — which is the one a naive
|
||||||
|
// implementation drops while it is busy moving focus.
|
||||||
|
await expect(field).toHaveValue('ring til Anders i dag');
|
||||||
|
|
||||||
|
// And the legend appears with the field, saying what the parser can read. It
|
||||||
|
// used to live in the placeholder, which vanishes at the first keystroke —
|
||||||
|
// exactly when the help starts being useful.
|
||||||
|
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.getByText('ring til Anders', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// In English too. The shortcut used to find the field by its Danish label, so
|
||||||
|
// it silently did nothing for anybody running the interface in English.
|
||||||
|
await page.goto('/indstillinger');
|
||||||
|
await page.getByLabel('Sprog').selectOption('en');
|
||||||
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('body').click({ position: { x: 5, y: 5 } });
|
||||||
|
await page.keyboard.type('call Anders');
|
||||||
|
await expect(page.locator('[data-quickadd]')).toHaveValue('call Anders');
|
||||||
|
|
||||||
|
// Back to Danish. The account is shared with every test after this one, and
|
||||||
|
// leaving it in English made three of them hunt for buttons by names that no
|
||||||
|
// longer existed — a failure that looks like the feature under test and is not.
|
||||||
|
await page.goto('/indstillinger');
|
||||||
|
await page.getByLabel('Language').selectOption('da');
|
||||||
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Gem' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en postkasse kan tilføjes, og værtens afvisning når frem til skærmen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/indstillinger/integrationer');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Postkasser' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Tilføj en postkasse' }).click();
|
||||||
|
|
||||||
|
// Nothing is listening there, so the dial fails at once. The point is not the
|
||||||
|
// failure but where it is reported: the server tests the connection before it
|
||||||
|
// saves, so this is refused at the door rather than accepted and then silently
|
||||||
|
// failing every ten minutes.
|
||||||
|
await page.getByLabel('IMAP-server').fill('127.0.0.1:1');
|
||||||
|
await page.getByLabel('Brugernavn').fill('kw');
|
||||||
|
await page.getByLabel('App-kodeord').fill('hemmelig');
|
||||||
|
// `exact`, because "Forbind" is a prefix of "Forbind Gmail" one panel up and
|
||||||
|
// Playwright matches an accessible name by substring unless told otherwise.
|
||||||
|
await page.getByRole('button', { name: 'Forbind', exact: true }).click();
|
||||||
|
|
||||||
|
// The host's own words, not "noget gik galt". This is the whole reason
|
||||||
|
// upstream refusals are answered as 422: a proxy may replace the body of a 5xx,
|
||||||
|
// and then the person is told nothing.
|
||||||
|
await expect(page.getByText(/Postkassen svarede/)).toBeVisible();
|
||||||
|
|
||||||
|
// And nothing was saved on the way past.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText('127.0.0.1:1')).toBeHidden();
|
||||||
|
|
||||||
|
// The 422 above is the point of the test, not a symptom. Everything else the
|
||||||
|
// watcher caught still has to be empty.
|
||||||
|
expect(trouble.filter((t) => !t.includes('/mailboxes → 422'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PORT = 8097;
|
const PORT = 8097;
|
||||||
|
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
||||||
|
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||||
|
// every browser makes. The passkey test cannot run against a loopback address.
|
||||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -118,7 +121,15 @@ export default defineConfig({
|
|||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
VERDANDE_ADDR: `:${PORT}`,
|
VERDANDE_ADDR: `:${PORT}`,
|
||||||
VERDANDE_BASE_URL: baseURL,
|
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||||
|
// different things: the suite's address is where the browser goes, and
|
||||||
|
// this one is what the server calls itself — which is now also the
|
||||||
|
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||||
|
//
|
||||||
|
// The passkey test navigates to localhost explicitly so its origin
|
||||||
|
// matches. Everything else stays on the loopback address, where it has
|
||||||
|
// always been.
|
||||||
|
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||||
VERDANDE_DATA_DIR: dataDir,
|
VERDANDE_DATA_DIR: dataDir,
|
||||||
// Off: an update check would reach out to GitHub from a test run.
|
// Off: an update check would reach out to GitHub from a test run.
|
||||||
VERDANDE_UPDATE_CHECK: 'false'
|
VERDANDE_UPDATE_CHECK: 'false'
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const SHOTS = [
|
|||||||
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
|
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
|
||||||
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
|
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
|
||||||
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
|
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
|
||||||
|
// The same project as a list, which is where sections read as bands rather than
|
||||||
|
// as columns — and the shape most people actually work in.
|
||||||
|
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
|
||||||
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
||||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||||
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
||||||
@@ -59,6 +62,11 @@ const SHOTS = [
|
|||||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function showList(page) {
|
||||||
|
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
}
|
||||||
|
|
||||||
async function showMonth(page) {
|
async function showMonth(page) {
|
||||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(800);
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ const MESSAGES = {
|
|||||||
ai_not_configured: 'error.aiNotConfigured',
|
ai_not_configured: 'error.aiNotConfigured',
|
||||||
totp_not_enabled: 'error.totpNotEnabled',
|
totp_not_enabled: 'error.totpNotEnabled',
|
||||||
inbox_protected: 'error.inboxProtected',
|
inbox_protected: 'error.inboxProtected',
|
||||||
|
// The fallback for when Gmail refused and said nothing usable. When it did say
|
||||||
|
// something, `humanMessage` quotes it instead — see below.
|
||||||
|
gmail_failed: 'error.gmailRefused',
|
||||||
|
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
||||||
|
// this when it did not.
|
||||||
|
mailbox_failed: 'error.mailboxRefused',
|
||||||
last_admin: 'error.lastAdmin'
|
last_admin: 'error.lastAdmin'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +73,18 @@ const MESSAGES = {
|
|||||||
*/
|
*/
|
||||||
export function humanMessage(err) {
|
export function humanMessage(err) {
|
||||||
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
|
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
|
||||||
|
|
||||||
|
// Gmail's own words are the diagnosis — "invalid_grant", "insufficient
|
||||||
|
// authentication scopes" — and a generic sentence in their place throws away
|
||||||
|
// the only thing that says what to do. Wrapped so it reads as a quotation
|
||||||
|
// rather than as a stray English string in a Danish interface.
|
||||||
|
if (err.code === 'mailbox_failed' && err.message) {
|
||||||
|
return t('error.mailboxSaid', { what: err.message });
|
||||||
|
}
|
||||||
|
if (err.code === 'gmail_failed' && err.message) {
|
||||||
|
return t('error.gmailSaid', { what: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
const key = MESSAGES[err.code];
|
const key = MESSAGES[err.code];
|
||||||
if (key) return t(key);
|
if (key) return t(key);
|
||||||
return err.message ?? t(MESSAGES.internal_error);
|
return err.message ?? t(MESSAGES.internal_error);
|
||||||
@@ -181,6 +199,15 @@ export const api = {
|
|||||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
||||||
listSessions: () => get('/auth/sessions'),
|
listSessions: () => get('/auth/sessions'),
|
||||||
|
|
||||||
|
// --- passkeys
|
||||||
|
listPasskeys: () => get('/auth/passkeys'),
|
||||||
|
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
|
||||||
|
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
|
||||||
|
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
|
||||||
|
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
|
||||||
|
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
|
||||||
|
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
|
||||||
endSession: (id) => del(`/auth/sessions/${id}`),
|
endSession: (id) => del(`/auth/sessions/${id}`),
|
||||||
|
|
||||||
// --- users, administrators only
|
// --- users, administrators only
|
||||||
@@ -190,6 +217,8 @@ export const api = {
|
|||||||
deleteUser: (id) => del(`/users/${id}`),
|
deleteUser: (id) => del(`/users/${id}`),
|
||||||
revokeInvite: (id) => del(`/invites/${id}`),
|
revokeInvite: (id) => del(`/invites/${id}`),
|
||||||
listErrors: () => get('/errors'),
|
listErrors: () => get('/errors'),
|
||||||
|
panelStatus: () => get('/panel'),
|
||||||
|
restartFromPanel: () => post('/panel/restart'),
|
||||||
|
|
||||||
// The nightly backup, which ran since the beginning with nothing to show it.
|
// The nightly backup, which ran since the beginning with nothing to show it.
|
||||||
// The OAuth client itself, which is the instance's registration with Google
|
// The OAuth client itself, which is the instance's registration with Google
|
||||||
@@ -331,6 +360,13 @@ export const api = {
|
|||||||
authorizeGmail: () => post('/gmail/authorize'),
|
authorizeGmail: () => post('/gmail/authorize'),
|
||||||
syncGmail: () => post('/gmail/sync'),
|
syncGmail: () => post('/gmail/sync'),
|
||||||
|
|
||||||
|
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
|
||||||
|
// there is no instance-wide registration behind these the way Gmail has one.
|
||||||
|
mailboxes: () => get('/mailboxes'),
|
||||||
|
addMailbox: (body) => post('/mailboxes', body),
|
||||||
|
deleteMailbox: (id) => del(`/mailboxes/${id}`),
|
||||||
|
syncMailbox: (id) => post(`/mailboxes/${id}/sync`),
|
||||||
|
|
||||||
getAISettings: () => get('/ai/settings'),
|
getAISettings: () => get('/ai/settings'),
|
||||||
setAISettings: (data) => put('/ai/settings', data),
|
setAISettings: (data) => put('/ai/settings', data),
|
||||||
aiSummary: () => post('/ai/summary'),
|
aiSummary: () => post('/ai/summary'),
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
app.replace(id, previous);
|
app.replace(id, previous);
|
||||||
app.toast('Kunne ikke flytte opgaven.');
|
app.toast(t('task.moveFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,6 +21,19 @@
|
|||||||
let spans = $state([]);
|
let spans = $state([]);
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let input;
|
let input;
|
||||||
|
let focused = $state(false);
|
||||||
|
|
||||||
|
// Kept beside the parser's own vocabulary on purpose: a legend that promises
|
||||||
|
// something internal/quickadd cannot read is worse than no legend. These four
|
||||||
|
// are what Parse recognises as marks; dates it reads as prose, which is why the
|
||||||
|
// last entry is an example rather than a symbol.
|
||||||
|
let syntax = $derived([
|
||||||
|
{ mark: '#', what: t('task.syntaxProject') },
|
||||||
|
{ mark: '/', what: t('task.syntaxSection') },
|
||||||
|
{ mark: '@', what: t('task.syntaxLabel') },
|
||||||
|
{ mark: 'p1', what: t('task.syntaxPriority') },
|
||||||
|
{ mark: t('task.syntaxDateMark'), what: t('task.syntaxDate') }
|
||||||
|
]);
|
||||||
|
|
||||||
let previewController = null;
|
let previewController = null;
|
||||||
let previewTimer = null;
|
let previewTimer = null;
|
||||||
@@ -143,7 +156,10 @@
|
|||||||
bind:this={input}
|
bind:this={input}
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
|
onfocus={() => (focused = true)}
|
||||||
|
onblur={() => (focused = false)}
|
||||||
type="text"
|
type="text"
|
||||||
|
data-quickadd
|
||||||
placeholder={t('task.placeholder')}
|
placeholder={t('task.placeholder')}
|
||||||
aria-label={t('task.new')}
|
aria-label={t('task.new')}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -154,10 +170,52 @@
|
|||||||
{#if text.trim()}
|
{#if text.trim()}
|
||||||
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- What the parser understands, spelled out while somebody is typing into it.
|
||||||
|
It was in the placeholder before, which is the one place a hint cannot be:
|
||||||
|
the placeholder disappears at the first keystroke, so the help was gone
|
||||||
|
exactly when it started being useful.
|
||||||
|
|
||||||
|
Floated rather than in the flow. In the flow it pushed the whole list down
|
||||||
|
the moment the field took focus, and a click aimed at the first task landed
|
||||||
|
on the second — twelve tests caught it, which is twelve more than the eye
|
||||||
|
would have. -->
|
||||||
|
{#if focused}
|
||||||
|
<p class="syntax">
|
||||||
|
{#each syntax as item}
|
||||||
|
<span><kbd>{item.mark}</kbd> {item.what}</span>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
|
||||||
|
.syntax {
|
||||||
|
/* Nothing to click, and it floats over the first task in the list — which it
|
||||||
|
promptly swallowed the clicks of. Legends are for reading. */
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s1) var(--s3);
|
||||||
|
padding: var(--s1) 0 0 var(--s4);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.syntax kbd {
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.quickadd {
|
.quickadd {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app, sidebar } from '$lib/stores.svelte.js';
|
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
@@ -105,17 +105,18 @@
|
|||||||
await app.renameGroup(group.id, name);
|
await app.renameGroup(group.id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeGroup(group) {
|
/** Double-click, or F2 with the row focused. The colour is chosen in the same
|
||||||
const count = inGroup(group.id).length;
|
* form, because it is the same edit to the same thing. */
|
||||||
// Says what happens to the projects, because "slet gruppen" reads like it
|
/** Cleared by a second click, which means the double was a rename. */
|
||||||
// might take them with it. It does not: only the heading goes.
|
let navTimer;
|
||||||
const kept =
|
|
||||||
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
function startRename(group) {
|
||||||
const question = t('sidebar.deleteGroup', { name: group.name });
|
clearTimeout(navTimer);
|
||||||
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
renamingGroup = group.id;
|
||||||
await app.deleteGroup(group.id);
|
groupName = group.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- resizing -------------------------------------------------------------------
|
// --- resizing -------------------------------------------------------------------
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false);
|
||||||
@@ -410,7 +411,7 @@
|
|||||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
{#if project.shared}
|
{#if project.shared}
|
||||||
<span class="count">{project.member_count}</span>
|
<span class="count">{project.member_count}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -547,6 +548,13 @@
|
|||||||
overId = null;
|
overId = null;
|
||||||
}}
|
}}
|
||||||
ondrop={(e) => onGroupDrop(e, group)}
|
ondrop={(e) => onGroupDrop(e, group)}
|
||||||
|
ondblclick={() => startRename(group)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'F2') {
|
||||||
|
e.preventDefault();
|
||||||
|
startRename(group);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if renamingGroup === group.id}
|
{#if renamingGroup === group.id}
|
||||||
<!-- Renaming is where the colour is chosen too.
|
<!-- Renaming is where the colour is chosen too.
|
||||||
@@ -618,7 +626,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h2 class="fold">
|
<h2 class="fold">
|
||||||
<a href="/gruppe/{group.id}" onclick={onnavigate}>
|
<!-- Navigation waits two tenths of a second for a second click. A
|
||||||
|
link cannot know a double is coming without pausing, and the
|
||||||
|
alternative was worse than the pause: the first click opened the
|
||||||
|
page while the rename form was appearing, and the colour swatches
|
||||||
|
moved out from under the pointer. Two tenths is under the double
|
||||||
|
click threshold and over the threshold of noticing. -->
|
||||||
|
<a
|
||||||
|
href="/gruppe/{group.id}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(navTimer);
|
||||||
|
if (e.detail > 1) return;
|
||||||
|
navTimer = setTimeout(() => {
|
||||||
|
onnavigate();
|
||||||
|
goto(`/gruppe/${group.id}`);
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="dot group-dot"
|
class="dot group-dot"
|
||||||
style="background: {colorVar(group.color)}"
|
style="background: {colorVar(group.color)}"
|
||||||
@@ -628,14 +653,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
<span class="count">{inside.length}</span>
|
<span class="count">{inside.length}</span>
|
||||||
<button
|
|
||||||
class="group-action"
|
|
||||||
onclick={() => {
|
|
||||||
renamingGroup = group.id;
|
|
||||||
groupName = group.name;
|
|
||||||
}}>{t('sidebar.rename')}</button
|
|
||||||
>
|
|
||||||
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -939,14 +956,30 @@
|
|||||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
/* The chevron on its own, so the name beside it can be a link to the group's
|
||||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
||||||
you miss. */
|
you miss. */
|
||||||
|
/* The chevron sits on top of the group's dot rather than before it. In the flow
|
||||||
|
it pushed the name 22px further in than a project's, so groups read as if
|
||||||
|
they were filed under the projects above them — the opposite of what they
|
||||||
|
are. Out of the flow, a group's name starts exactly where a project's does.
|
||||||
|
The dot is the resting state; the chevron takes over on hover or focus, and
|
||||||
|
stays put while the group is folded, so a folded group still shows the way
|
||||||
|
back open. */
|
||||||
.chevron-button {
|
.chevron-button {
|
||||||
flex: none;
|
position: absolute;
|
||||||
|
left: calc(var(--s2) - 5px);
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head:hover .chevron-button,
|
||||||
|
.chevron-button:focus-visible,
|
||||||
|
.chevron-button[aria-expanded='false'] {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-button:hover {
|
.chevron-button:hover {
|
||||||
@@ -997,30 +1030,34 @@
|
|||||||
|
|
||||||
/* Hidden until the heading is hovered, like the section actions on a project
|
/* Hidden until the heading is hovered, like the section actions on a project
|
||||||
page: two buttons permanently beside a label stop it reading as a label. */
|
page: two buttons permanently beside a label stop it reading as a label. */
|
||||||
.group-action {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
padding: 0 var(--s1);
|
|
||||||
flex: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--fast) var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-head:hover .group-action,
|
|
||||||
.group-action:focus-visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-action:hover {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-action.remove:hover {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-head .count {
|
.folder-head .count {
|
||||||
margin-left: 0;
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without min-width a flex item refuses to shrink below its content, so a long
|
||||||
|
group name pushed the count off the row instead of ending in an ellipsis. */
|
||||||
|
.folder-head h2 {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No left padding of its own: the row already has the same padding as a project
|
||||||
|
row, and the anchor's would be added on top — which is where the last eight
|
||||||
|
pixels of the old indent were hiding. Vertical padding stays, so the heading
|
||||||
|
keeps its height. */
|
||||||
|
.folder-head h2 a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-head h2 a :global(*:last-child),
|
||||||
|
.folder-head h2 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The group's own mark, between the chevron and the name — inside the fold
|
/* The group's own mark, between the chevron and the name — inside the fold
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { available, signIn } from '$lib/passkey.js';
|
||||||
|
|
||||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||||
let mode = $state('loading');
|
let mode = $state('loading');
|
||||||
@@ -31,6 +32,41 @@
|
|||||||
let fields = $state({});
|
let fields = $state({});
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
|
||||||
|
// --- passkey -----------------------------------------------------------------
|
||||||
|
|
||||||
|
let passkeyReady = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
available().then((ok) => (passkeyReady = ok));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No email is asked for and none is sent: the device knows which account its
|
||||||
|
* key belongs to. That is not only convenience — it means this page cannot be
|
||||||
|
* used to find out who has an account here.
|
||||||
|
*/
|
||||||
|
async function signInWithPasskey() {
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await signIn();
|
||||||
|
if (result.totp_required) {
|
||||||
|
mode = 'totp';
|
||||||
|
} else {
|
||||||
|
app.user = result.user;
|
||||||
|
await app.load();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// A cancelled prompt is not a failure. Somebody changed their mind, and
|
||||||
|
// an error for that reads as though the key was rejected.
|
||||||
|
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
|
||||||
|
error = humanMessage(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The token from an emailed link, if this is one.
|
* The token from an emailed link, if this is one.
|
||||||
*
|
*
|
||||||
@@ -267,6 +303,15 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
|
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
|
||||||
|
|
||||||
|
<!-- Only when the browser has somewhere to keep a key. Offering it where
|
||||||
|
it cannot work is a button that fails for reasons nobody can act on. -->
|
||||||
|
{#if passkeyReady}
|
||||||
|
<button type="button" class="passkey" disabled={busy} onclick={signInWithPasskey}>
|
||||||
|
{busy ? t('passkey.signingIn') : t('passkey.signIn')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
|
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
|
||||||
{t('auth.forgot')}
|
{t('auth.forgot')}
|
||||||
</button>
|
</button>
|
||||||
@@ -381,6 +426,22 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Set apart from the primary button but not made secondary: it is an equal way
|
||||||
|
in, not a fallback. */
|
||||||
|
.passkey {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2) var(--s4);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink);
|
||||||
|
transition: border-color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkey:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* necessarily the last one sent.
|
* necessarily the last one sent.
|
||||||
*/
|
*/
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeComment(comment) {
|
async function removeComment(comment) {
|
||||||
if (!confirm('Slet kommentaren?')) return;
|
if (!confirm(t('detail.deleteComment'))) return;
|
||||||
const previous = comments;
|
const previous = comments;
|
||||||
comments = comments.filter((c) => c.id !== comment.id);
|
comments = comments.filter((c) => c.id !== comment.id);
|
||||||
try {
|
try {
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
<label for="project">{t('detail.project')}</label>
|
<label for="project">{t('detail.project')}</label>
|
||||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||||
{#each app.projects as project (project.id)}
|
{#each app.projects as project (project.id)}
|
||||||
<option value={project.id}>{project.name}</option>
|
<option value={project.id}>{projectName(project)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export const da = {
|
|||||||
// --- a task in a list ----------------------------------------------------------
|
// --- a task in a list ----------------------------------------------------------
|
||||||
'task.add': 'Tilføj',
|
'task.add': 'Tilføj',
|
||||||
'task.new': 'Ny opgave',
|
'task.new': 'Ny opgave',
|
||||||
|
'task.syntaxProject': 'projekt',
|
||||||
|
'task.syntaxSection': 'sektion',
|
||||||
|
'task.syntaxLabel': 'etiket',
|
||||||
|
'task.syntaxPriority': 'prioritet',
|
||||||
|
'task.syntaxDateMark': 'i morgen kl 10',
|
||||||
|
'task.syntaxDate': 'forfalder',
|
||||||
'task.placeholder': 'Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”',
|
'task.placeholder': 'Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”',
|
||||||
'task.complete': 'Markér som færdig',
|
'task.complete': 'Markér som færdig',
|
||||||
'task.reopen': 'Genåbn opgave',
|
'task.reopen': 'Genåbn opgave',
|
||||||
@@ -360,6 +366,8 @@ export const da = {
|
|||||||
'Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som de var — bortset fra opgaver, du havde slettet hver for sig først; dem var der en grund til.',
|
'Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som de var — bortset fra opgaver, du havde slettet hver for sig først; dem var der en grund til.',
|
||||||
'data.backupsHint':
|
'data.backupsHint':
|
||||||
'Databasen kopieres hver nat og de seneste fjorten gemmes. Kopierne er talt, ikke dateret: en container, der har stået slukket en måned, må ikke komme tilbage og slette dem alle for at være for gamle.',
|
'Databasen kopieres hver nat og de seneste fjorten gemmes. Kopierne er talt, ikke dateret: en container, der har stået slukket en måned, må ikke komme tilbage og slette dem alle for at være for gamle.',
|
||||||
|
'data.backupsSecrets':
|
||||||
|
'Tokens og kodeord til postkasser ligger krypteret i kopien, og nøglen gør ikke: den ligger i en fil ved siden af databasen. Det er meningen — en kopi, der kommer på afveje, åbner ikke din mail. Men gendanner du en kopi et sted uden den fil, skal postkasserne forbindes forfra.',
|
||||||
'data.backupsAttachments':
|
'data.backupsAttachments':
|
||||||
'Vedhæftede filer er ikke med. De er indholdsadresserede og bliver kun lagt til, så fjorten ens kopier af dem ville fylde det drev op, de skulle beskytte — tag hele /data, hvis du vil have det hele.',
|
'Vedhæftede filer er ikke med. De er indholdsadresserede og bliver kun lagt til, så fjorten ens kopier af dem ville fylde det drev op, de skulle beskytte — tag hele /data, hvis du vil have det hele.',
|
||||||
'data.restoreHint':
|
'data.restoreHint':
|
||||||
@@ -369,6 +377,8 @@ export const da = {
|
|||||||
'data.templatesHint':
|
'data.templatesHint':
|
||||||
'Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er relative, så de bliver regnet ud fra den startdato, du vælger.',
|
'Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er relative, så de bliver regnet ud fra den startdato, du vælger.',
|
||||||
|
|
||||||
|
'keys.anyLetter': 'et bogstav',
|
||||||
|
'keys.typeToCapture': 'begynder en opgave, med bogstavet i feltet',
|
||||||
'keys.navigation': 'Navigation',
|
'keys.navigation': 'Navigation',
|
||||||
'keys.jumpToQuickAdd': 'Hop til feltet “tilføj opgave”',
|
'keys.jumpToQuickAdd': 'Hop til feltet “tilføj opgave”',
|
||||||
'keys.thisPage': 'Denne side',
|
'keys.thisPage': 'Denne side',
|
||||||
@@ -449,6 +459,22 @@ export const da = {
|
|||||||
'detail.attachFile': 'Vedhæft en fil',
|
'detail.attachFile': 'Vedhæft en fil',
|
||||||
|
|
||||||
// --- settings: integrations --------------------------------------------------------
|
// --- settings: integrations --------------------------------------------------------
|
||||||
|
'int.mailboxes': 'Postkasser',
|
||||||
|
'int.mailboxesHint':
|
||||||
|
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave. Én vej: fjerner du flaget, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
|
||||||
|
'int.addMailbox': 'Tilføj en postkasse',
|
||||||
|
'int.host': 'IMAP-server',
|
||||||
|
'int.username': 'Brugernavn',
|
||||||
|
'int.appPassword': 'App-kodeord',
|
||||||
|
'int.appPasswordHint':
|
||||||
|
'Ikke dit almindelige kodeord. iCloud og de fleste andre laver et særskilt et til programmer, som du kan trække tilbage alene. Det gemmes krypteret.',
|
||||||
|
'int.connect': 'Forbind',
|
||||||
|
'int.connecting': 'Forbinder …',
|
||||||
|
'int.disconnect': 'Kobl fra',
|
||||||
|
'int.disconnectMailbox': 'Kobl {name} fra? Opgaverne, den har lavet, bliver.',
|
||||||
|
'int.mailboxConnected': 'Postkassen er forbundet.',
|
||||||
|
'int.fetchNow': 'Hent nu',
|
||||||
|
'int.fetching': 'Henter …',
|
||||||
'int.gmail': 'Gmail',
|
'int.gmail': 'Gmail',
|
||||||
'int.gmailHint':
|
'int.gmailHint':
|
||||||
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
|
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
|
||||||
@@ -497,6 +523,9 @@ export const da = {
|
|||||||
'int.server': 'Server',
|
'int.server': 'Server',
|
||||||
'int.caldavAuth':
|
'int.caldavAuth':
|
||||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||||
|
'int.gmailFetchedOne': '{n} opgave hentet.',
|
||||||
|
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||||
|
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||||
'int.gmailConnected': 'Gmail er forbundet.',
|
'int.gmailConnected': 'Gmail er forbundet.',
|
||||||
'int.gmailState':
|
'int.gmailState':
|
||||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||||
@@ -613,5 +642,59 @@ export const da = {
|
|||||||
'done.title': 'Færdige',
|
'done.title': 'Færdige',
|
||||||
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
|
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
|
||||||
'done.empty': 'Du har ikke lukket noget endnu.',
|
'done.empty': 'Du har ikke lukket noget endnu.',
|
||||||
'done.unknownDay': 'Uden dato'
|
'done.unknownDay': 'Uden dato',
|
||||||
|
|
||||||
|
// --- passkeys ----------------------------------------------------------------------
|
||||||
|
'passkey.title': 'Passkeys',
|
||||||
|
'passkey.hint':
|
||||||
|
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
|
||||||
|
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
|
||||||
|
'passkey.none': 'Ingen nøgler endnu.',
|
||||||
|
'passkey.name': 'Navn på nøglen',
|
||||||
|
'passkey.namePlaceholder': 'fx min bærbare',
|
||||||
|
'passkey.add': 'Tilføj en nøgle',
|
||||||
|
'passkey.registering': 'Venter på enheden…',
|
||||||
|
'passkey.remove': 'Fjern',
|
||||||
|
'passkey.removeQuestion': 'Fjern "{name}"? Enheden kan ikke logge ind med den bagefter.',
|
||||||
|
'passkey.bothFactors': 'begge faktorer',
|
||||||
|
'passkey.possessionOnly': 'kun besiddelse — koden bliver stadig krævet',
|
||||||
|
'passkey.lastUsed': 'sidst brugt {when}',
|
||||||
|
'passkey.neverUsed': 'aldrig brugt',
|
||||||
|
'passkey.signIn': 'Log ind med en passkey',
|
||||||
|
'passkey.signingIn': 'Venter på enheden…',
|
||||||
|
|
||||||
|
'passkey.unavailable':
|
||||||
|
'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.',
|
||||||
|
|
||||||
|
// --- restarting from the interface ---------------------------------------------
|
||||||
|
'update.restart': 'Genstart og hent nyeste version',
|
||||||
|
'update.restarting': 'Genstarter…',
|
||||||
|
'update.restartQuestion':
|
||||||
|
'Genstart nu? Serveren er nede i omkring et halvt minut, og henter den nyeste version på vejen op.',
|
||||||
|
'update.asking': 'Beder panelet om at genstarte…',
|
||||||
|
'update.waiting': 'Venter på at serveren kommer op igen…',
|
||||||
|
'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
|
||||||
|
'update.notConfigured':
|
||||||
|
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
|
||||||
|
|
||||||
|
'error.gmailSaid': 'Gmail svarede: {what}',
|
||||||
|
|
||||||
|
'error.mailboxRefused': 'Postkassen kunne ikke læses.',
|
||||||
|
'error.mailboxSaid': 'Postkassen svarede: {what}',
|
||||||
|
'error.gmailRefused': 'Gmail afviste kaldet. Prøv at forbinde postkassen igen.',
|
||||||
|
|
||||||
|
'int.newAddressQuestion': 'Ny adresse? Mail til den gamle bliver afvist.',
|
||||||
|
'int.copied': 'Kopieret.',
|
||||||
|
'int.copyFailed': 'Kunne ikke kopiere — markér teksten i stedet.',
|
||||||
|
|
||||||
|
'detail.deleteComment': 'Slet kommentaren?',
|
||||||
|
'int.disconnectQuestion': 'Afbryd forbindelsen til Gmail? Tokens bliver glemt.',
|
||||||
|
'int.newLinkQuestion': 'Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.',
|
||||||
|
'data.deleteTemplate': 'Slet skabelonen "{name}"?',
|
||||||
|
'tokens.revokeQuestion': 'Tilbagekald "{name}"? Alt, der bruger den, holder op med at virke.',
|
||||||
|
'account.signOutThisDevice': 'Det er denne enhed. Du bliver logget ud.',
|
||||||
|
|
||||||
|
'project.deleteQuestion': 'Slet "{name}" med alle dets opgaver?',
|
||||||
|
'project.deleteRecoverable':
|
||||||
|
'Det havner i papirkurven og kan hentes tilbage under Indstillinger → Data og skabeloner i {days} dage.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export const en = {
|
|||||||
// --- a task in a list ----------------------------------------------------------
|
// --- a task in a list ----------------------------------------------------------
|
||||||
'task.add': 'Add',
|
'task.add': 'Add',
|
||||||
'task.new': 'New task',
|
'task.new': 'New task',
|
||||||
|
'task.syntaxProject': 'project',
|
||||||
|
'task.syntaxSection': 'section',
|
||||||
|
'task.syntaxLabel': 'label',
|
||||||
|
'task.syntaxPriority': 'priority',
|
||||||
|
'task.syntaxDateMark': 'tomorrow at 10',
|
||||||
|
'task.syntaxDate': 'due',
|
||||||
'task.placeholder': 'Add a task — try “pay VAT tomorrow at 10 p1 #Company”',
|
'task.placeholder': 'Add a task — try “pay VAT tomorrow at 10 p1 #Company”',
|
||||||
'task.complete': 'Mark as done',
|
'task.complete': 'Mark as done',
|
||||||
'task.reopen': 'Reopen task',
|
'task.reopen': 'Reopen task',
|
||||||
@@ -355,6 +361,8 @@ export const en = {
|
|||||||
'Deleted projects, with their tasks. They come back exactly as they were — except for tasks you had deleted individually first; there was a reason for those.',
|
'Deleted projects, with their tasks. They come back exactly as they were — except for tasks you had deleted individually first; there was a reason for those.',
|
||||||
'data.backupsHint':
|
'data.backupsHint':
|
||||||
'The database is copied every night and the most recent fourteen are kept. Counted, not dated: a container that has been off for a month must not come back and delete all of them for being too old.',
|
'The database is copied every night and the most recent fourteen are kept. Counted, not dated: a container that has been off for a month must not come back and delete all of them for being too old.',
|
||||||
|
'data.backupsSecrets':
|
||||||
|
'Tokens and mailbox passwords are encrypted inside the copy, and the key is not: it sits in a file beside the database. That is the point — a copy that goes astray does not open your mail. But restore a copy somewhere without that file and the mailboxes must be connected again.',
|
||||||
'data.backupsAttachments':
|
'data.backupsAttachments':
|
||||||
'Attachments are not included. They are content-addressed and only ever added to, so fourteen identical copies would fill the volume they were meant to protect — take the whole of /data if you want everything.',
|
'Attachments are not included. They are content-addressed and only ever added to, so fourteen identical copies would fill the volume they were meant to protect — take the whole of /data if you want everything.',
|
||||||
'data.restoreHint':
|
'data.restoreHint':
|
||||||
@@ -364,6 +372,8 @@ export const en = {
|
|||||||
'data.templatesHint':
|
'data.templatesHint':
|
||||||
'A project saved as a shape you can use again. The dates in a template are relative, so they are worked out from the start date you pick.',
|
'A project saved as a shape you can use again. The dates in a template are relative, so they are worked out from the start date you pick.',
|
||||||
|
|
||||||
|
'keys.anyLetter': 'any letter',
|
||||||
|
'keys.typeToCapture': 'starts a task, with the letter already in the field',
|
||||||
'keys.navigation': 'Navigation',
|
'keys.navigation': 'Navigation',
|
||||||
'keys.jumpToQuickAdd': 'Jump to the “add a task” field',
|
'keys.jumpToQuickAdd': 'Jump to the “add a task” field',
|
||||||
'keys.thisPage': 'This page',
|
'keys.thisPage': 'This page',
|
||||||
@@ -445,6 +455,22 @@ export const en = {
|
|||||||
'detail.attachFile': 'Attach a file',
|
'detail.attachFile': 'Attach a file',
|
||||||
|
|
||||||
// --- settings: integrations --------------------------------------------------------
|
// --- settings: integrations --------------------------------------------------------
|
||||||
|
'int.mailboxes': 'Mailboxes',
|
||||||
|
'int.mailboxesHint':
|
||||||
|
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task. One way: removing the flag does nothing to the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
|
||||||
|
'int.addMailbox': 'Add a mailbox',
|
||||||
|
'int.host': 'IMAP server',
|
||||||
|
'int.username': 'Username',
|
||||||
|
'int.appPassword': 'App password',
|
||||||
|
'int.appPasswordHint':
|
||||||
|
'Not your ordinary password. iCloud and most others issue a separate one for applications, which you can withdraw on its own. It is stored encrypted.',
|
||||||
|
'int.connect': 'Connect',
|
||||||
|
'int.connecting': 'Connecting …',
|
||||||
|
'int.disconnect': 'Disconnect',
|
||||||
|
'int.disconnectMailbox': 'Disconnect {name}? The tasks it made will stay.',
|
||||||
|
'int.mailboxConnected': 'The mailbox is connected.',
|
||||||
|
'int.fetchNow': 'Fetch now',
|
||||||
|
'int.fetching': 'Fetching …',
|
||||||
'int.gmail': 'Gmail',
|
'int.gmail': 'Gmail',
|
||||||
'int.gmailHint':
|
'int.gmailHint':
|
||||||
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
|
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
|
||||||
@@ -493,6 +519,9 @@ export const en = {
|
|||||||
'int.server': 'Server',
|
'int.server': 'Server',
|
||||||
'int.caldavAuth':
|
'int.caldavAuth':
|
||||||
'The username is your email. The password is an API token — not your real password.',
|
'The username is your email. The password is an API token — not your real password.',
|
||||||
|
'int.gmailFetchedOne': '{n} task fetched.',
|
||||||
|
'int.gmailFetchedMany': '{n} tasks fetched.',
|
||||||
|
'int.gmailNothingNew': 'No new messages.',
|
||||||
'int.gmailConnected': 'Gmail is connected.',
|
'int.gmailConnected': 'Gmail is connected.',
|
||||||
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
|
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
|
||||||
'int.gmailExpired': 'The attempt expired on the way. Try again.',
|
'int.gmailExpired': 'The attempt expired on the way. Try again.',
|
||||||
@@ -608,5 +637,59 @@ export const en = {
|
|||||||
'done.title': 'Done',
|
'done.title': 'Done',
|
||||||
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
|
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
|
||||||
'done.empty': 'You have not closed anything yet.',
|
'done.empty': 'You have not closed anything yet.',
|
||||||
'done.unknownDay': 'No date'
|
'done.unknownDay': 'No date',
|
||||||
|
|
||||||
|
// --- passkeys ----------------------------------------------------------------------
|
||||||
|
'passkey.title': 'Passkeys',
|
||||||
|
'passkey.hint':
|
||||||
|
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
|
||||||
|
'passkey.unsupported': 'This browser does not support passkeys.',
|
||||||
|
'passkey.none': 'No keys yet.',
|
||||||
|
'passkey.name': 'Name for the key',
|
||||||
|
'passkey.namePlaceholder': 'e.g. my laptop',
|
||||||
|
'passkey.add': 'Add a key',
|
||||||
|
'passkey.registering': 'Waiting for the device…',
|
||||||
|
'passkey.remove': 'Remove',
|
||||||
|
'passkey.removeQuestion': 'Remove "{name}"? That device cannot sign in with it afterwards.',
|
||||||
|
'passkey.bothFactors': 'both factors',
|
||||||
|
'passkey.possessionOnly': 'possession only — the code is still required',
|
||||||
|
'passkey.lastUsed': 'last used {when}',
|
||||||
|
'passkey.neverUsed': 'never used',
|
||||||
|
'passkey.signIn': 'Sign in with a passkey',
|
||||||
|
'passkey.signingIn': 'Waiting for the device…',
|
||||||
|
|
||||||
|
'passkey.unavailable':
|
||||||
|
'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.',
|
||||||
|
|
||||||
|
// --- restarting from the interface ---------------------------------------------
|
||||||
|
'update.restart': 'Restart and pull the latest version',
|
||||||
|
'update.restarting': 'Restarting…',
|
||||||
|
'update.restartQuestion':
|
||||||
|
'Restart now? The server is down for about half a minute, and pulls the newest version on the way back up.',
|
||||||
|
'update.asking': 'Asking the panel to restart…',
|
||||||
|
'update.waiting': 'Waiting for the server to come back…',
|
||||||
|
'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
|
||||||
|
'update.notConfigured':
|
||||||
|
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
|
||||||
|
|
||||||
|
'error.gmailSaid': 'Gmail said: {what}',
|
||||||
|
|
||||||
|
'error.mailboxRefused': 'The mailbox could not be read.',
|
||||||
|
'error.mailboxSaid': 'The mailbox answered: {what}',
|
||||||
|
'error.gmailRefused': 'Gmail refused the call. Try connecting the mailbox again.',
|
||||||
|
|
||||||
|
'int.newAddressQuestion': 'A new address? Mail to the old one is refused.',
|
||||||
|
'int.copied': 'Copied.',
|
||||||
|
'int.copyFailed': 'Could not copy — select the text instead.',
|
||||||
|
|
||||||
|
'detail.deleteComment': 'Delete the comment?',
|
||||||
|
'int.disconnectQuestion': 'Disconnect Gmail? The tokens are forgotten.',
|
||||||
|
'int.newLinkQuestion': 'A new feed link? Every existing subscription stops working.',
|
||||||
|
'data.deleteTemplate': 'Delete the template "{name}"?',
|
||||||
|
'tokens.revokeQuestion': 'Revoke "{name}"? Everything using it stops working.',
|
||||||
|
'account.signOutThisDevice': 'That is this device. You will be signed out.',
|
||||||
|
|
||||||
|
'project.deleteQuestion': 'Delete "{name}" with all its tasks?',
|
||||||
|
'project.deleteRecoverable':
|
||||||
|
'It goes to the trash and can be restored under Settings → Data and templates for {days} days.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* The browser half of a passkey ceremony.
|
||||||
|
*
|
||||||
|
* WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every
|
||||||
|
* boundary between this and the server is a base64url conversion. Getting one of
|
||||||
|
* them wrong produces a signature that will not verify, reported as "that key was
|
||||||
|
* not accepted" — which reads as a broken key rather than a broken encoder. That
|
||||||
|
* is the whole reason this is one file and not three call sites.
|
||||||
|
*/
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
/** Whether this browser can do it at all. */
|
||||||
|
export function supported() {
|
||||||
|
return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a key can be offered without the person typing anything first.
|
||||||
|
*
|
||||||
|
* Not the same question as `supported()`: a browser can do WebAuthn and still have
|
||||||
|
* nowhere to put a discoverable credential. Asking first is what stops the sign-in
|
||||||
|
* page offering a button that cannot work.
|
||||||
|
*/
|
||||||
|
export async function available() {
|
||||||
|
if (!supported()) return false;
|
||||||
|
try {
|
||||||
|
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBuffer = (value) => {
|
||||||
|
const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '=');
|
||||||
|
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBase64URL = (buffer) =>
|
||||||
|
btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
|
||||||
|
/** Registers a key on the signed-in account. Returns the row the server stored. */
|
||||||
|
export async function register(name) {
|
||||||
|
const { challenge_id, options } = await api.beginPasskeyRegistration();
|
||||||
|
const publicKey = {
|
||||||
|
...options.publicKey,
|
||||||
|
challenge: toBuffer(options.publicKey.challenge),
|
||||||
|
user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) },
|
||||||
|
excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: toBuffer(c.id)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({ publicKey });
|
||||||
|
if (!credential) throw new Error('cancelled');
|
||||||
|
|
||||||
|
return api.finishPasskeyRegistration({
|
||||||
|
challenge_id,
|
||||||
|
name,
|
||||||
|
credential: {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: toBase64URL(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
|
||||||
|
attestationObject: toBase64URL(credential.response.attestationObject)
|
||||||
|
},
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs in with a key.
|
||||||
|
*
|
||||||
|
* No email is asked for and none is sent: the device knows which account its key
|
||||||
|
* belongs to. That is not only convenience — it means the sign-in page cannot be
|
||||||
|
* used to find out who has an account here.
|
||||||
|
*/
|
||||||
|
export async function signIn() {
|
||||||
|
const { challenge_id, options } = await api.beginPasskeyLogin();
|
||||||
|
const publicKey = {
|
||||||
|
...options.publicKey,
|
||||||
|
challenge: toBuffer(options.publicKey.challenge),
|
||||||
|
allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: toBuffer(c.id)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.get({ publicKey });
|
||||||
|
if (!credential) throw new Error('cancelled');
|
||||||
|
|
||||||
|
return api.finishPasskeyLogin({
|
||||||
|
challenge_id,
|
||||||
|
credential: {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: toBase64URL(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
|
||||||
|
authenticatorData: toBase64URL(credential.response.authenticatorData),
|
||||||
|
signature: toBase64URL(credential.response.signature),
|
||||||
|
userHandle: credential.response.userHandle
|
||||||
|
? toBase64URL(credential.response.userHandle)
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -620,6 +620,28 @@ class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to call a project on screen.
|
||||||
|
*
|
||||||
|
* Almost always its own name — somebody wrote it and it is theirs. The Inbox is the
|
||||||
|
* exception: the server names it at account creation, before anybody has seen a
|
||||||
|
* settings page, so it is the one project name nobody chose. Leaving it as stored
|
||||||
|
* meant an account created in Danish said "Indbakke" in an English interface, which
|
||||||
|
* reads as a string somebody forgot to translate. It was.
|
||||||
|
*
|
||||||
|
* Only while it still carries a name the server gave it. Rename it and that is your
|
||||||
|
* name for it, in every language — the same rule as any other project.
|
||||||
|
*/
|
||||||
|
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
||||||
|
|
||||||
|
export function projectName(project) {
|
||||||
|
if (!project) return '';
|
||||||
|
if (project.is_inbox && SYSTEM_INBOX_NAMES.has(project.name.trim().toLowerCase())) {
|
||||||
|
return t('nav.inbox');
|
||||||
|
}
|
||||||
|
return project.name;
|
||||||
|
}
|
||||||
|
|
||||||
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
||||||
function truncate(text, max = 40) {
|
function truncate(text, max = 40) {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
|
|||||||
@@ -29,16 +29,31 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global shortcuts, mapped the way Todoist maps them so muscle memory carries
|
* Global keys.
|
||||||
* over. They are ignored while a text field has focus — otherwise typing "t"
|
*
|
||||||
* in a task title would navigate away mid-sentence.
|
* Typing anywhere starts a task, with the letter already in the field. That is
|
||||||
|
* the whole gesture: no button to hit first, no shortcut to remember, and the
|
||||||
|
* thought you had while looking at a list does not have to survive a journey to
|
||||||
|
* find somewhere to put it.
|
||||||
|
*
|
||||||
|
* It costs the single-letter navigation. "t" for today and "u" for upcoming
|
||||||
|
* cannot also be the first letter of "tal med Anders" — one keyboard cannot
|
||||||
|
* serve both, and capturing is the thing done fifty times a day. Navigation
|
||||||
|
* lives in the palette, which does more and is one keystroke away.
|
||||||
|
*
|
||||||
|
* Ignored while a field already has focus, while a task is open — the drawer is
|
||||||
|
* a place you are, not one you pass through — and for anything held with a
|
||||||
|
* modifier, which belongs to the browser and the system.
|
||||||
*/
|
*/
|
||||||
function onkeydown(event) {
|
function onkeydown(event) {
|
||||||
const el = event.target;
|
const el = event.target;
|
||||||
const typing =
|
// Only when nothing has focus. The first version asked the narrower question
|
||||||
el instanceof HTMLInputElement ||
|
// — "is this a text field?" — and so captured a letter typed at a focused
|
||||||
el instanceof HTMLTextAreaElement ||
|
// select, button or link, which is where that letter belongs. A key pressed
|
||||||
el?.isContentEditable;
|
// at something is meant for it; only a key pressed at the page itself is
|
||||||
|
// spare.
|
||||||
|
const idle = el === document.body || el === document.documentElement;
|
||||||
|
const typing = !idle;
|
||||||
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -46,25 +61,24 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
// The navigation shortcuts are for the list. With a task open, "t" means the
|
|
||||||
// letter t — the drawer is a place you are, not a place you pass through.
|
|
||||||
if (app.detailId) return;
|
if (app.detailId) return;
|
||||||
|
|
||||||
switch (event.key) {
|
// One character, so Tab, Escape, the arrows and the function keys stay
|
||||||
case 'q':
|
// themselves. Space is excluded too: a task that begins with a space begins
|
||||||
event.preventDefault();
|
// with a mis-hit, and space is what people press to scroll a long list.
|
||||||
document.querySelector('input[aria-label="Ny opgave"]')?.focus();
|
if (event.key.length !== 1 || event.key === ' ') return;
|
||||||
break;
|
|
||||||
case 't':
|
// Found by a marker rather than by its label. This used to look the field up
|
||||||
goto('/');
|
// by aria-label="Ny opgave", which meant the shortcut quietly did nothing for
|
||||||
break;
|
// anybody running the interface in English.
|
||||||
case 'u':
|
const field = document.querySelector('[data-quickadd]');
|
||||||
goto('/upcoming');
|
if (!field) return;
|
||||||
break;
|
|
||||||
case '?':
|
event.preventDefault();
|
||||||
goto('/genveje');
|
field.focus();
|
||||||
break;
|
field.value += event.key;
|
||||||
}
|
// Svelte binds on input, and setting .value in script does not raise one.
|
||||||
|
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
/** The shortcuts, listed. Mapped to Todoist's so muscle memory carries over. */
|
/**
|
||||||
|
* The shortcuts, listed.
|
||||||
|
*
|
||||||
|
* The single-letter navigation this page used to carry — q, t, u — is gone.
|
||||||
|
* Typing anywhere now starts a task, and one keyboard cannot both spell "tal
|
||||||
|
* med Anders" and jump to Today on the same "t". Navigation moved into the
|
||||||
|
* palette, which does more and costs one keystroke.
|
||||||
|
*/
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
const groups = $derived([
|
const groups = $derived([
|
||||||
{
|
{
|
||||||
title: t('keys.navigation'),
|
title: t('keys.navigation'),
|
||||||
keys: [
|
keys: [
|
||||||
['q', t('keys.jumpToQuickAdd')],
|
[t('keys.anyLetter'), t('keys.typeToCapture')],
|
||||||
['t', t('nav.today')],
|
['⌘K / Ctrl+K', t('nav.search')]
|
||||||
['u', t('nav.upcoming')],
|
|
||||||
['⌘K / Ctrl+K', t('nav.search')],
|
|
||||||
['?', t('keys.thisPage')]
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
* projects answers is what is left.
|
* projects answers is what is left.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
@@ -29,6 +30,18 @@
|
|||||||
load(id);
|
load(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Says what happens to the projects, because "delete the group" reads like it
|
||||||
|
* might take them with it. It does not: only the heading goes. */
|
||||||
|
async function removeGroup() {
|
||||||
|
const count = projects.length;
|
||||||
|
const kept =
|
||||||
|
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
||||||
|
const question = t('sidebar.deleteGroup', { name: group.name });
|
||||||
|
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
||||||
|
await app.deleteGroup(group.id);
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
async function load(groupId) {
|
async function load(groupId) {
|
||||||
if (!groupId) return;
|
if (!groupId) return;
|
||||||
status = 'loading';
|
status = 'loading';
|
||||||
@@ -165,7 +178,7 @@
|
|||||||
<a href="/projekt/{project.id}">
|
<a href="/projekt/{project.id}">
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
<!-- Counted by the server in the same request. Open tasks, not all
|
<!-- Counted by the server in the same request. Open tasks, not all
|
||||||
of them: "12" beside a project finished last year is a number
|
of them: "12" beside a project finished last year is a number
|
||||||
that means nothing. -->
|
that means nothing. -->
|
||||||
@@ -208,6 +221,14 @@
|
|||||||
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
|
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Deleting lives here rather than in the sidebar, where two words sat on
|
||||||
|
top of the group's name and where you could press them without having
|
||||||
|
seen what the group holds. Here the projects are on the screen above
|
||||||
|
it, which is the whole argument for the button being here. -->
|
||||||
|
<section class="danger">
|
||||||
|
<button class="remove" onclick={removeGroup}>{t('sidebar.delete')}</button>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,6 +251,22 @@
|
|||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
margin-top: var(--s4);
|
||||||
|
padding-top: var(--s4);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger .remove {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: var(--s2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger .remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -3,12 +3,67 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||||
|
import { supported, register } from '$lib/passkey.js';
|
||||||
|
import { ago } from '$lib/when.js';
|
||||||
|
|
||||||
// --- profile ---------------------------------------------------------------
|
// --- profile ---------------------------------------------------------------
|
||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let timezone = $state('');
|
let timezone = $state('');
|
||||||
let locale = $state('da');
|
let locale = $state('da');
|
||||||
|
|
||||||
|
// --- passkeys -------------------------------------------------------------------
|
||||||
|
|
||||||
|
let passkeys = $state([]);
|
||||||
|
let passkeyName = $state('');
|
||||||
|
let passkeyError = $state('');
|
||||||
|
let registering = $state(false);
|
||||||
|
let passkeySupported = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
passkeySupported = supported();
|
||||||
|
if (!passkeySupported) return;
|
||||||
|
api
|
||||||
|
.listPasskeys()
|
||||||
|
.then((r) => {
|
||||||
|
passkeys = r.passkeys;
|
||||||
|
// The server has the other half of the answer: this browser can do
|
||||||
|
// passkeys, and this deployment may still be on an address no
|
||||||
|
// authenticator will accept.
|
||||||
|
passkeySupported = r.available;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addPasskey(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
passkeyError = '';
|
||||||
|
registering = true;
|
||||||
|
try {
|
||||||
|
passkeys = [...passkeys, await register(passkeyName.trim())];
|
||||||
|
passkeyName = '';
|
||||||
|
} catch (e) {
|
||||||
|
// A cancelled prompt is not a failure — the person changed their mind,
|
||||||
|
// and an error message for that reads as though something broke.
|
||||||
|
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
|
||||||
|
passkeyError = humanMessage(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
registering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePasskey(key) {
|
||||||
|
if (!confirm(t('passkey.removeQuestion', { name: key.name }))) return;
|
||||||
|
const previous = passkeys;
|
||||||
|
passkeys = passkeys.filter((k) => k.id !== key.id);
|
||||||
|
try {
|
||||||
|
await api.deletePasskey(key.id);
|
||||||
|
} catch (e) {
|
||||||
|
passkeys = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
let profileErrors = $state({});
|
let profileErrors = $state({});
|
||||||
let profileSaved = $state(false);
|
let profileSaved = $state(false);
|
||||||
let savingProfile = $state(false);
|
let savingProfile = $state(false);
|
||||||
@@ -160,7 +215,7 @@
|
|||||||
|
|
||||||
async function endSession(session) {
|
async function endSession(session) {
|
||||||
if (session.current) {
|
if (session.current) {
|
||||||
if (!confirm('Det er denne enhed. Du bliver logget ud.')) return;
|
if (!confirm(t('account.signOutThisDevice'))) return;
|
||||||
await api.endSession(session.id);
|
await api.endSession(session.id);
|
||||||
// A full reload rather than clearing the store by hand: the session is
|
// A full reload rather than clearing the store by hand: the session is
|
||||||
// gone, so every open socket and every cached answer in this tab is now
|
// gone, so every open socket and every cached answer in this tab is now
|
||||||
@@ -187,16 +242,6 @@
|
|||||||
* older than a week gets its date instead: "for 23 dage siden" is a number
|
* older than a week gets its date instead: "for 23 dage siden" is a number
|
||||||
* nobody can place.
|
* nobody can place.
|
||||||
*/
|
*/
|
||||||
function ago(iso) {
|
|
||||||
const then = new Date(iso);
|
|
||||||
const seconds = Math.round((Date.now() - then) / 1000);
|
|
||||||
|
|
||||||
if (seconds < 60) return 'lige nu';
|
|
||||||
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
|
||||||
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
|
||||||
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
|
|
||||||
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -413,6 +458,47 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
||||||
|
is a way in, and it is a thing on a device you might later want to revoke. -->
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>{t('passkey.title')}</h2>
|
||||||
|
<p class="hint">{t('passkey.hint')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !passkeySupported}
|
||||||
|
<p class="hint">{t('passkey.unavailable')}</p>
|
||||||
|
{:else}
|
||||||
|
{#if passkeys.length}
|
||||||
|
<ul class="list">
|
||||||
|
{#each passkeys as key (key.id)}
|
||||||
|
<li>
|
||||||
|
<div class="what">
|
||||||
|
<span class="primary-line">{key.name}</span>
|
||||||
|
<span class="secondary">
|
||||||
|
{key.user_verified ? t('passkey.bothFactors') : t('passkey.possessionOnly')}
|
||||||
|
·
|
||||||
|
{key.last_used_at ? t('passkey.lastUsed', { when: ago(key.last_used_at) }) : t('passkey.neverUsed')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="secondary" onclick={() => removePasskey(key)}>{t('passkey.remove')}</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">{t('passkey.none')}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="row" onsubmit={addPasskey}>
|
||||||
|
<input bind:value={passkeyName} placeholder={t('passkey.namePlaceholder')} aria-label={t('passkey.name')} />
|
||||||
|
<button class="primary" type="submit" disabled={registering}>
|
||||||
|
{registering ? t('passkey.registering') : t('passkey.add')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.devices')}</h2>
|
<h2>{t('account.devices')}</h2>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTemplate(template) {
|
async function deleteTemplate(template) {
|
||||||
if (!confirm(`Slet skabelonen "${template.name}"?`)) return;
|
if (!confirm(t('data.deleteTemplate', { name: template.name }))) return;
|
||||||
|
|
||||||
const previous = templates;
|
const previous = templates;
|
||||||
templates = templates.filter((t) => t.id !== template.id);
|
templates = templates.filter((t) => t.id !== template.id);
|
||||||
@@ -267,6 +267,13 @@
|
|||||||
<p class="hint">
|
<p class="hint">
|
||||||
{t('data.backupsAttachments')}
|
{t('data.backupsAttachments')}
|
||||||
</p>
|
</p>
|
||||||
|
<!-- Said here rather than only in the documentation: this is the page where
|
||||||
|
somebody downloads the file, and the day the key matters is the day
|
||||||
|
they are restoring it somewhere else and wondering why the mailbox is
|
||||||
|
silent. -->
|
||||||
|
<p class="hint">
|
||||||
|
{t('data.backupsSecrets')}
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* audit log must not have.
|
* audit log must not have.
|
||||||
*/
|
*/
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { eventName, eventDetail } from '$lib/events.js';
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { ago } from '$lib/when.js';
|
import { ago } from '$lib/when.js';
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
<select bind:value={filterProject}>
|
<select bind:value={filterProject}>
|
||||||
<option value="">{t('history.all')}</option>
|
<option value="">{t('history.all')}</option>
|
||||||
{#each projects as project (project.id)}
|
{#each projects as project (project.id)}
|
||||||
<option value={project.id}>{project.name}</option>
|
<option value={project.id}>{projectName(project)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t, plural } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
// --- Gmail ------------------------------------------------------------------
|
// --- Gmail ------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -115,7 +115,9 @@
|
|||||||
try {
|
try {
|
||||||
const result = await api.syncGmail();
|
const result = await api.syncGmail();
|
||||||
app.toast(
|
app.toast(
|
||||||
result?.created ? `${result.created} opgave(r) hentet.` : 'Ingen nye beskeder.'
|
result?.created
|
||||||
|
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
|
||||||
|
: t('int.gmailNothingNew')
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
@@ -125,7 +127,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectGmail() {
|
async function disconnectGmail() {
|
||||||
if (!confirm('Afbryd forbindelsen til Gmail? Tokens bliver glemt.')) return;
|
if (!confirm(t('int.disconnectQuestion'))) return;
|
||||||
try {
|
try {
|
||||||
await api.disconnectGmail();
|
await api.disconnectGmail();
|
||||||
gmail = await api.getGmail();
|
gmail = await api.getGmail();
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function rotateFeed() {
|
async function rotateFeed() {
|
||||||
if (!confirm('Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.')) return;
|
if (!confirm(t('int.newLinkQuestion'))) return;
|
||||||
try {
|
try {
|
||||||
feedURL = (await api.rotateFeed()).url;
|
feedURL = (await api.rotateFeed()).url;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -162,7 +164,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function rotateMail() {
|
async function rotateMail() {
|
||||||
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
|
if (!confirm(t('int.newAddressQuestion'))) return;
|
||||||
try {
|
try {
|
||||||
const r = await api.rotateMailAddress();
|
const r = await api.rotateMailAddress();
|
||||||
mailAddress = r.address;
|
mailAddress = r.address;
|
||||||
@@ -175,9 +177,76 @@
|
|||||||
async function copy(text) {
|
async function copy(text) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
app.toast('Kopieret.');
|
app.toast(t('int.copied'));
|
||||||
} catch {
|
} catch {
|
||||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
app.toast(t('int.copyFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mailboxes ----------------------------------------------------------------
|
||||||
|
|
||||||
|
let mailboxes = $state([]);
|
||||||
|
let addingMailbox = $state(false);
|
||||||
|
let connecting = $state(false);
|
||||||
|
let syncingId = $state(null);
|
||||||
|
let newMailbox = $state({ host: '', username: '', password: '' });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadMailboxes();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMailboxes() {
|
||||||
|
try {
|
||||||
|
mailboxes = (await api.mailboxes()).mailboxes ?? [];
|
||||||
|
} catch {
|
||||||
|
// A settings page that will not render because one panel could not load
|
||||||
|
// is worse than a panel that is empty.
|
||||||
|
mailboxes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMailbox(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
connecting = true;
|
||||||
|
try {
|
||||||
|
// The server dials before it saves, so an error here is the host's own
|
||||||
|
// refusal — a wrong app password, a host that is not listening.
|
||||||
|
await api.addMailbox({ ...newMailbox });
|
||||||
|
newMailbox = { host: '', username: '', password: '' };
|
||||||
|
addingMailbox = false;
|
||||||
|
await loadMailboxes();
|
||||||
|
app.toast(t('int.mailboxConnected'));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
connecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncMailbox(box) {
|
||||||
|
syncingId = box.id;
|
||||||
|
try {
|
||||||
|
const result = await api.syncMailbox(box.id);
|
||||||
|
app.toast(
|
||||||
|
result?.created
|
||||||
|
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
|
||||||
|
: t('int.gmailNothingNew')
|
||||||
|
);
|
||||||
|
await loadMailboxes();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
} finally {
|
||||||
|
syncingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMailbox(box) {
|
||||||
|
if (!confirm(t('int.disconnectMailbox', { name: box.name }))) return;
|
||||||
|
try {
|
||||||
|
await api.deleteMailbox(box.id);
|
||||||
|
await loadMailboxes();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -350,6 +419,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Mailboxes read over IMAP: iCloud, Fastmail, any ordinary host. Its own panel
|
||||||
|
rather than a second mode inside the Gmail one, because they are not the same
|
||||||
|
thing wearing different clothes — one is an app you sign in through, the other
|
||||||
|
is a password you hold. -->
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>{t('int.mailboxes')}</h2>
|
||||||
|
<p class="hint">{t('int.mailboxesHint')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if mailboxes.length}
|
||||||
|
<ul class="mailboxes">
|
||||||
|
{#each mailboxes as box (box.id)}
|
||||||
|
<li>
|
||||||
|
<div class="what">
|
||||||
|
<strong>{box.name}</strong>
|
||||||
|
<span class="hint">{box.username} · {box.host}</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost" onclick={() => syncMailbox(box)} disabled={syncingId === box.id}>
|
||||||
|
{syncingId === box.id ? t('int.fetching') : t('int.fetchNow')}
|
||||||
|
</button>
|
||||||
|
<button class="ghost remove" onclick={() => removeMailbox(box)}>
|
||||||
|
{t('int.disconnect')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if addingMailbox}
|
||||||
|
<form class="add-mailbox" onsubmit={addMailbox}>
|
||||||
|
<div class="field">
|
||||||
|
<label for="mb-host">{t('int.host')}</label>
|
||||||
|
<input id="mb-host" bind:value={newMailbox.host} placeholder="imap.mail.me.com:993" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="mb-user">{t('int.username')}</label>
|
||||||
|
<input id="mb-user" bind:value={newMailbox.username} autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="mb-pass">{t('int.appPassword')}</label>
|
||||||
|
<input id="mb-pass" type="password" bind:value={newMailbox.password} autocomplete="off" />
|
||||||
|
<p class="hint">{t('int.appPasswordHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" class="primary" disabled={connecting}>
|
||||||
|
{connecting ? t('int.connecting') : t('int.connect')}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ghost" onclick={() => (addingMailbox = false)}>
|
||||||
|
{t('account.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="row">
|
||||||
|
<button class="ghost" onclick={() => (addingMailbox = true)}>{t('int.addMailbox')}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Folded away once it is set: it is a one-off registration, and a panel that
|
/* Folded away once it is set: it is a one-off registration, and a panel that
|
||||||
keeps showing its own setup instructions after setup reads as unfinished. */
|
keeps showing its own setup instructions after setup reads as unfinished. */
|
||||||
|
|||||||
@@ -89,6 +89,55 @@
|
|||||||
|
|
||||||
let version = $state(null);
|
let version = $state(null);
|
||||||
|
|
||||||
|
// --- restarting from here ---------------------------------------------------
|
||||||
|
|
||||||
|
let panel = $state(null);
|
||||||
|
let restarting = $state(false);
|
||||||
|
let restartNote = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.panelStatus()
|
||||||
|
.then((p) => (panel = p))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the panel to recreate this container, then waits for it to come back.
|
||||||
|
*
|
||||||
|
* The reply may never arrive — the panel stops this container as part of
|
||||||
|
* answering — so the answer is not believed either way. `/healthz` is polled
|
||||||
|
* until it responds, which is the only thing that actually says "it is up".
|
||||||
|
*/
|
||||||
|
async function restart() {
|
||||||
|
if (!confirm(t('update.restartQuestion'))) return;
|
||||||
|
restarting = true;
|
||||||
|
restartNote = t('update.asking');
|
||||||
|
try {
|
||||||
|
await api.restartFromPanel();
|
||||||
|
} catch (e) {
|
||||||
|
// A request cut off mid-flight is the successful case wearing a
|
||||||
|
// failure's clothes. The poll below decides.
|
||||||
|
restartNote = humanMessage(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
restartNote = t('update.waiting');
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
const response = await fetch('/healthz', { cache: 'no-store' });
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Still down, which is expected for the first few seconds.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restarting = false;
|
||||||
|
restartNote = t('update.stillDown');
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
api.version().then((v) => (version = v)).catch(() => {});
|
api.version().then((v) => (version = v)).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -182,6 +231,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Restarting is what makes a new version arrive: a container cannot replace
|
||||||
|
its own image, and the panel pulls `:latest` when it recreates one. The
|
||||||
|
button is here rather than in another browser tab. -->
|
||||||
|
{#if panel?.configured}
|
||||||
|
<div class="row">
|
||||||
|
<button class="secondary" onclick={restart} disabled={restarting}>
|
||||||
|
{restarting ? t('update.restarting') : t('update.restart')}
|
||||||
|
</button>
|
||||||
|
{#if restartNote}<span class="saved">{restartNote}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if panel}
|
||||||
|
<p class="hint">{t('update.notConfigured', { missing: panel.missing.join(', ') })}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if version.update_available}
|
{#if version.update_available}
|
||||||
{#if version.notes}
|
{#if version.notes}
|
||||||
<p class="notes">{version.notes}</p>
|
<p class="notes">{version.notes}</p>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
} catch {
|
} catch {
|
||||||
// Insecure context, or permission refused. The token is on screen and
|
// Insecure context, or permission refused. The token is on screen and
|
||||||
// selectable, so this is a convenience that failed, not the feature.
|
// selectable, so this is a convenience that failed, not the feature.
|
||||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
app.toast(t('int.copyFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,12 +69,12 @@
|
|||||||
await navigator.clipboard.writeText(connectorURL);
|
await navigator.clipboard.writeText(connectorURL);
|
||||||
connectorCopied = true;
|
connectorCopied = true;
|
||||||
} catch {
|
} catch {
|
||||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
app.toast(t('int.copyFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revoke(token) {
|
async function revoke(token) {
|
||||||
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
if (!confirm(t('tokens.revokeQuestion', { name: token.name })))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const previous = tokens;
|
const previous = tokens;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, completedView } from '$lib/stores.svelte.js';
|
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.js';
|
import { COLORS, colorVar } from '$lib/colors.js';
|
||||||
import { eventName, eventDetail } from '$lib/events.js';
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
@@ -188,9 +188,9 @@
|
|||||||
async function remove() {
|
async function remove() {
|
||||||
const days = 30;
|
const days = 30;
|
||||||
const confirmed = confirm(
|
const confirmed = confirm(
|
||||||
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
t('project.deleteQuestion', { name: projectName(project) }) +
|
||||||
`Det havner i papirkurven og kan hentes tilbage under ` +
|
'\n\n' +
|
||||||
`Indstillinger → Data og skabeloner i ${days} dage.`
|
t('project.deleteRecoverable', { days })
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
@@ -402,7 +402,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="view">
|
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
||||||
|
in CSS: this was `.view:has(.board)` for as long as the board has existed and
|
||||||
|
never once matched, because `.board` is inside a child component and Svelte
|
||||||
|
scopes a selector to the component that wrote it. The rule read correctly,
|
||||||
|
compiled, and could not fire. Kommende has done it this way all along. -->
|
||||||
|
<div class="view" class:wide={mode !== 'list'}>
|
||||||
{#if project}
|
{#if project}
|
||||||
<header>
|
<header>
|
||||||
<!-- A heading first, a field second. Swapping the h1 out for an input
|
<!-- A heading first, a field second. Swapping the h1 out for an input
|
||||||
@@ -429,10 +434,10 @@
|
|||||||
<h1 class:renameable={isOwner}>
|
<h1 class:renameable={isOwner}>
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
|
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
{project.name}
|
{projectName(project)}
|
||||||
{/if}
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -816,6 +821,12 @@
|
|||||||
padding: var(--s6) var(--s4) var(--s8);
|
padding: var(--s6) var(--s4) var(--s8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A board and a month grid need the room a reading column deliberately
|
||||||
|
withholds. Same allowance Kommende makes. */
|
||||||
|
.view.wide {
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
/* A board and a month grid need the room; a list is easier to read narrow. */
|
/* A board and a month grid need the room; a list is easier to read narrow. */
|
||||||
|
|
||||||
/* Wraps, and the title keeps a floor.
|
/* Wraps, and the title keeps a floor.
|
||||||
@@ -1262,19 +1273,39 @@
|
|||||||
margin-bottom: var(--s2);
|
margin-bottom: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A band rather than a rule.
|
||||||
|
*
|
||||||
|
* Small uppercase text over a hairline is a heading you have to look for. A
|
||||||
|
* project with four sections is four groups, and the eye should be able to
|
||||||
|
* count them without reading a word — so the heading gets a ground of its own,
|
||||||
|
* and the rows below sit against the page.
|
||||||
|
*
|
||||||
|
* `--surface`, not a tint of the accent: the colour on this page belongs to the
|
||||||
|
* project, and a section is not a second thing to colour. What is wanted is
|
||||||
|
* separation, and a step in lightness gives that in all five themes without
|
||||||
|
* anybody measuring anything.
|
||||||
|
*/
|
||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
border-bottom: 1px solid var(--line);
|
background: var(--surface);
|
||||||
margin-bottom: var(--s2);
|
border-radius: var(--radius);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
/* Pulled out to the padding of the rows below, so the band lines up with the
|
||||||
|
text it heads rather than with the rows' own inset. */
|
||||||
|
margin-left: calc(var(--s2) * -1);
|
||||||
|
margin-right: calc(var(--s2) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head h2 {
|
.section-head h2 {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden until the section is hovered or something in it has focus: a heading
|
/* Hidden until the section is hovered or something in it has focus: a heading
|
||||||
|
|||||||