Compare commits
@@ -0,0 +1,46 @@
|
||||
name: Bug
|
||||
# Gitea kalder feltet `about`, hvor GitHub kalder det `description`, og Gitea
|
||||
# *kræver* det: en skabelon uden bliver afvist frem for at blive vist. Derfor
|
||||
# ligger skabelonerne her for sig frem for at være delt med `.github/`.
|
||||
about: Something does not work
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: And what you expected instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to reproduce it
|
||||
placeholder: |
|
||||
1. …
|
||||
2. …
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The image tag, or the commit you built from.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: how
|
||||
attributes:
|
||||
label: How are you running it
|
||||
options:
|
||||
- As a Rune in Yggdrasil Panel
|
||||
- Docker directly
|
||||
- From source
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Anything in the logs
|
||||
description: Please remove tokens and email addresses first.
|
||||
render: text
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security issue
|
||||
url: https://gitea.nolimit.dk/kw/verdande/src/branch/main/SECURITY.md
|
||||
about: Please report vulnerabilities by email, not as a public issue.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature
|
||||
about: Suggest something
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
# Absolut adresse, ikke GitHubs `../../blob/main/…`: Gitea lægger en fil
|
||||
# under `/src/branch/<gren>/`, så den relative form peger ingen steder her.
|
||||
value: |
|
||||
Please check the [non-goals](https://gitea.nolimit.dk/kw/verdande/src/branch/main/README.md#non-goals)
|
||||
first — some things are deliberately not being built.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What are you trying to do
|
||||
description: The problem, not the solution. What does the current version make hard?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: What you had in mind
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,273 @@
|
||||
name: CI
|
||||
|
||||
# Gitea-tvillingen til .github/workflows/ci.yml. Den prøver at nå samme resultat
|
||||
# ad en anden vej, og forskellen er hele pointen med filen:
|
||||
#
|
||||
# INGEN `uses:`. Gitea Actions kan læse GitHubs format, men `DEFAULT_ACTIONS_URL`
|
||||
# står ikke i app.ini på gitea.nolimit.dk, og standarden er github.com — så et
|
||||
# `uses: actions/checkout@v4` her ville blive hentet FRA GitHub, hver gang, også
|
||||
# selvom både koden, runneren og registret er vores egne. Et workflow, der bruger
|
||||
# markedspladsen, er derfor ikke uafhængigt af GitHub; det ser bare sådan ud.
|
||||
#
|
||||
# Prisen er, at værktøjskæden skal komme et sted fra. Den kommer fra jobbets
|
||||
# container, hvilket er billigere end det lyder: `container: golang:1.26` er
|
||||
# nøjagtig det, setup-go ellers ville bruge et trin på at installere.
|
||||
#
|
||||
# Alternativerne, hvis nogen senere vil skrive filen om: sæt DEFAULT_ACTIONS_URL
|
||||
# til gitea.com, som spejler de gængse handlinger — men det flytter kun
|
||||
# afhængigheden fra én andens server til en andens, og det er en indstilling for
|
||||
# HELE instansen, altså også for alt andet, der bygger der. Denne fil rører kun
|
||||
# dette repo.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Så en kørsel kan udløses uden at skulle opfinde en commit. Det er ikke
|
||||
# bekvemmelighed: uden den er den eneste måde at få CI til at køre igen mod
|
||||
# uændret kode at skubbe noget tomt, og en tom commit i historikken er en
|
||||
# løgn om, at der skete noget.
|
||||
workflow_dispatch:
|
||||
|
||||
# Ingen `concurrency:`-blok. GitHub-udgaven aflyser en kørsel, når en nyere
|
||||
# kommer, og det er stadig det rigtige at gøre — men understøttelsen kom sent til
|
||||
# Gitea Actions, og et felt, runneren ikke kender, er en fejl ved oversættelsen
|
||||
# frem for en ignoreret linje. Den kan sættes på, når instansen er en version,
|
||||
# der kan.
|
||||
|
||||
jobs:
|
||||
go:
|
||||
name: Go
|
||||
runs-on: ubuntu-latest
|
||||
# Containeren ER værktøjskæden. `-race` kræver en C-oversætter, og den er med
|
||||
# i det officielle golang-image — et slankere ville koste et apt-trin.
|
||||
container:
|
||||
image: golang:1.26
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# Håndrullet, fordi actions/checkout ville blive hentet fra GitHub.
|
||||
# Det er de samme fire greb, den handling tager: init, remote, en
|
||||
# fetch af præcis den commit, der udløste kørslen, og et checkout.
|
||||
#
|
||||
# Legitimationen sættes med -c på selve fetch-kommandoen frem for i
|
||||
# remotens adresse. En adresse med token i havner i .git/config, i
|
||||
# `git remote -v` og i enhver fejlbesked, der citerer den; en header
|
||||
# gør ikke.
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files are not gofmt'd:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
spec:
|
||||
name: OpenAPI is valid
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# Structural validity only. That the spec matches the *router* is checked by
|
||||
# a Go test, which is the half that actually goes stale.
|
||||
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
|
||||
|
||||
e2e:
|
||||
name: End-to-end
|
||||
runs-on: ubuntu-latest
|
||||
# node frem for golang her: Playwrights browsere trækker et lag
|
||||
# Debian-pakker, som npm-imaget er vant til at have, mens Go til gengæld er
|
||||
# fire linjer at hælde ind. Den anden vej rundt er den dyre.
|
||||
container:
|
||||
image: node:22
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Go
|
||||
run: |
|
||||
set -eu
|
||||
# Versionen læses ud af go.mod frem for at stå her. To steder at rette
|
||||
# er ét for mange, og den slags glemmes lige indtil CI bygger med en
|
||||
# anden Go end udviklerne.
|
||||
version=$(awk '/^go /{print $2; exit}' go.mod)
|
||||
arch=$(dpkg --print-architecture)
|
||||
curl -fsSL "https://go.dev/dl/go${version}.linux-${arch}.tar.gz" | tar -xz -C /usr/local
|
||||
echo /usr/local/go/bin >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# Chromium for the suite, WebKit for one test.
|
||||
#
|
||||
# A smoke test exists to catch a build that does not work at all, and running
|
||||
# the whole thing in three engines finds the same breakage three times for
|
||||
# three times the cost. Focus is the exception, and it took a real report to
|
||||
# learn it: Safari ignores `autofocus` on anything inserted after the page has
|
||||
# loaded, so every inline form in the app opened with no cursor in it. WebKit
|
||||
# here is the same engine Safari is, and `webkit-forms` in the Playwright
|
||||
# config greps a single test out of the suite to run in it.
|
||||
- name: Install browsers
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||
# playwright.config.js. There is nothing to build here first.
|
||||
- name: Test
|
||||
working-directory: web
|
||||
run: npm run test:e2e
|
||||
|
||||
# Ingen rapport at hente ned bagefter. GitHub-udgaven lægger
|
||||
# web/playwright-report/ op som et artefakt, og det trin ER en handling fra
|
||||
# markedspladsen — der er ikke en `run:`-form af det. Går suiten i stykker
|
||||
# her, står grunden i loggen frem for i en zip. Skal rapporten tilbage, er
|
||||
# det upload-artifact spejlet ind lokalt, ikke en linje mere i denne fil.
|
||||
|
||||
docker:
|
||||
name: Docker image builds
|
||||
runs-on: ubuntu-latest
|
||||
# Ingen `container:` her: jobbet skal bruge docker-klienten og buildx, og dem
|
||||
# har runnerens eget ubuntu-image. En golang- eller node-container ville have
|
||||
# sproget og mangle værktøjet.
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# Build only — publishing happens on a tag, in release.yml. This job exists
|
||||
# so a Dockerfile that no longer builds fails on the pull request rather
|
||||
# than at release time.
|
||||
#
|
||||
# `type=cacheonly` frem for `--load`: resultatet skal ingen steder hen, og
|
||||
# at hente et image ind i daemonen for straks at glemme det er et halvt
|
||||
# minut hver kørsel. Én arkitektur er nok til at fange en Dockerfile, der
|
||||
# ikke bygger; de to rigtige bygges på et mærkat.
|
||||
- name: Build
|
||||
run: |
|
||||
set -eu
|
||||
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
|
||||
docker buildx build --platform linux/amd64 --output type=cacheonly .
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Release
|
||||
|
||||
# Samme udløser som GitHub-workflowet: et semver-tag, og ikke andet.
|
||||
#
|
||||
# Gitea Actions læser samme format som GitHub Actions, men det er *ikke* det
|
||||
# samme sted. Filen ligger derfor for sig frem for at være delt: der er intet
|
||||
# `secrets.GITHUB_TOKEN`, registret er et andet, og der er ingen Releases-side at
|
||||
# skrive til. En delt fil ville være fire betingede led, der hver kun gælder ét
|
||||
# af de to steder.
|
||||
#
|
||||
# Og som i ci.yml: ingen `uses:`. Handlinger hentes fra github.com, så længe
|
||||
# DEFAULT_ACTIONS_URL ikke er sat — se den lange forklaring der.
|
||||
on:
|
||||
push:
|
||||
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: Build and publish image
|
||||
# `ubuntu-latest` er runnerens egen etiket (`ovh-1`, docker-mode). Ingen
|
||||
# `container:`: jobbet skal bruge docker-klienten og buildx, og dem har
|
||||
# runnerens image.
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: gitea.nolimit.dk
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||
# ét bit at vide er for dyrt.
|
||||
godkendt=
|
||||
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||
godkendt=$bruger
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$godkendt" ]; then
|
||||
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout: godkendt som $godkendt"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# setup-qemu-action i rå form. Uden den bygger buildx kun værtens egen
|
||||
# arkitektur, og arm64 er ikke valgfrit: et hjemmelab er lige så tit en
|
||||
# Raspberry Pi eller en Apple silicon-VM som en x86-kasse.
|
||||
- name: Emulation for the other architecture
|
||||
run: docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64
|
||||
|
||||
- name: Builder
|
||||
run: docker buildx create --use --name release-verdande 2>/dev/null || docker buildx use release-verdande
|
||||
|
||||
# `GITEA_TOKEN` er automatisk til stede i et Actions-job og har lov at
|
||||
# skrive pakker for den, der ejer repoet. Ingen hemmelighed at sætte op.
|
||||
# --password-stdin frem for --password: det sidste ville stå i processens
|
||||
# kommandolinje, som enhver på maskinen kan læse.
|
||||
- name: Log in to the registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: printf '%s' "$TOKEN" | docker login "$REGISTRY" -u "$GITHUB_ACTOR" --password-stdin
|
||||
|
||||
# metadata-action i rå form.
|
||||
#
|
||||
# v1.4.2 udgiver 1.4.2, 1.4, 1 og latest — så en rune, der er bundet til
|
||||
# :1, får rettelser uden nogensinde at krydse et hovedversionsskifte.
|
||||
#
|
||||
# En forudgivelse (v1.4.2-rc1) får KUN sit eget nøjagtige mærkat. At lade
|
||||
# en rc overskrive :latest eller :1 ville sende den ud til alle, der troede
|
||||
# de fulgte det stabile spor — og mærkatmønsteret ovenfor lukker dem ind.
|
||||
- name: Work out the tags
|
||||
id: tags
|
||||
run: |
|
||||
set -eu
|
||||
version=${GITHUB_REF_NAME#v}
|
||||
image="$REGISTRY/$GITHUB_REPOSITORY"
|
||||
args="--tag $image:$version"
|
||||
case "$version" in
|
||||
*-*) echo "Forudgivelse: kun $version." ;;
|
||||
*)
|
||||
minor=${version%.*}
|
||||
major=${version%%.*}
|
||||
args="$args --tag $image:$minor --tag $image:$major --tag $image:latest"
|
||||
;;
|
||||
esac
|
||||
echo "args=$args" >> "$GITHUB_OUTPUT"
|
||||
echo "Mærkater: $args"
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
set -eu
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
${{ steps.tags.outputs.args }} \
|
||||
--build-arg "VERSION=$GITHUB_REF_NAME" \
|
||||
--label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
|
||||
--label "org.opencontainers.image.version=$GITHUB_REF_NAME" \
|
||||
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
.
|
||||
# Ingen `cache-from: type=gha`. act_runner har en cache-tjeneste, der
|
||||
# svarer på det navn, men den er ikke prøvet her, og en kold bygning,
|
||||
# der tager nogle minutter længere, er billigere end en udgivelse, der
|
||||
# fejler på noget, som ikke har med bygningen at gøre.
|
||||
|
||||
# Ingen Release oprettes.
|
||||
#
|
||||
# GitHub-workflowet laver en Release, fordi opdateringsnotitsen i appen læser
|
||||
# `releases/latest` derfra. Gitea har sit eget Releases-begreb, men notitsen
|
||||
# peger på GitHub — så en release her ville ingen læse. Skal notitsen flyttes
|
||||
# med, er det `internal/update`, der skal have en ny adresse, og det er en
|
||||
# ændring for sig. (Bemærk også, at GitHub-udgavens softprops-handling taler
|
||||
# mod api.github.com og ikke kan genbruges her.)
|
||||
@@ -66,3 +66,10 @@ node_modules.nosync/
|
||||
# Kept on disk because it is how a session hands over to the next one.
|
||||
HANDOFF.md
|
||||
NOTES-PLAN.md
|
||||
|
||||
# Samme grund: instruktionen til flytningen navngiver kassen, runneren, registret
|
||||
# og hvordan man kommer til dem. Arbejdspapir, ikke produktdokumentation.
|
||||
GITEA-FLYTNING.md
|
||||
|
||||
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
# Notes, as verdande builds them
|
||||
|
||||
A description of one working notes system: the data model, the syntax, the search,
|
||||
the rendering contract, the import and export formats, and — more useful than any
|
||||
of it — the decisions that look arbitrary until you know what they cost.
|
||||
|
||||
It is written to be lifted into another program. Nothing here depends on Go,
|
||||
SQLite or Svelte except where it says so; where an answer was forced by the tool,
|
||||
that is called out so you can pick a different one on purpose rather than by
|
||||
accident.
|
||||
|
||||
---
|
||||
|
||||
## Read this part first: what to ask before you build
|
||||
|
||||
**This document is not a drop-in. It describes a notes system that lives inside a
|
||||
task app with projects, roles, a trash and a search box, and about a third of its
|
||||
design is inherited from that.** Lifting it into a program with different
|
||||
surroundings without deciding which parts to keep produces something that works
|
||||
for a week and then fights you.
|
||||
|
||||
**If you are an assistant working from this file: ask these questions and get
|
||||
answers before writing code.** Do not guess a default. Each one changes what you
|
||||
build, not just how you build it.
|
||||
|
||||
### About the surroundings
|
||||
|
||||
1. **Does the host app already have a place to file things** — projects, folders,
|
||||
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
|
||||
filed them under the projects that already existed. If your app has nothing,
|
||||
you need an answer for where a note lives, and "a folder tree" is the expensive
|
||||
one.
|
||||
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
|
||||
through the whole model below. Single-user changes the schema and removes about
|
||||
half the permission surface.
|
||||
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
|
||||
it, deleting is either permanent or you are building a trash.
|
||||
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
|
||||
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
|
||||
ranking section below changes accordingly.
|
||||
|
||||
### About the notes themselves
|
||||
|
||||
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
|
||||
That decision is defended below; it is also the one that most constrains
|
||||
everything else.
|
||||
6. **Is the first line the title, or is there a title field?** Both work. Deriving
|
||||
it means one less thing to keep in step and one less thing a person can leave
|
||||
blank, and it forces the rule "the first line is a heading" on the editor.
|
||||
7. **What links to what?** `#project`, `[[note]]` and task references exist here
|
||||
because the app has projects and tasks. Yours may have none of them, or others.
|
||||
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
|
||||
authorisation path and an XSS surface. Skipping them is a legitimate answer.
|
||||
|
||||
### About scale and language
|
||||
|
||||
9. **How many notes, realistically?** Several choices below flip at around ten
|
||||
thousand: the list payload, the search fallback, rendering the whole list.
|
||||
10. **Which languages?** The folding described under *Search* is Danish. Every
|
||||
language has its own version of that problem and the generic answer (strip
|
||||
diacritics) is wrong for several of them.
|
||||
11. **Where does the text end up besides the screen?** Search indexes, AI
|
||||
features, exports and backups each read it. Plaintext makes all of them cheap
|
||||
and makes a stolen backup expensive — see *What is still open*.
|
||||
|
||||
### About integration, specifically
|
||||
|
||||
12. **Who owns the note's identity — you or the other system?** If notes will sync
|
||||
with something else, ids and timestamps are the whole problem, and the
|
||||
`updated_at` rule below becomes the first thing you have to change.
|
||||
13. **Is this a read of somebody else's notes, or the store of record?** A copy
|
||||
needs conflict rules; a store of record needs export.
|
||||
14. **What happens to a note whose project, task or link target is deleted?**
|
||||
Answered here as "the link goes dead and the note is untouched". If your host
|
||||
app cascades instead, say so before the first delete.
|
||||
|
||||
---
|
||||
|
||||
## The shape of a note
|
||||
|
||||
```
|
||||
id an opaque string, unique
|
||||
project_id nullable — where it is filed, or loose
|
||||
title derived, never set by a caller
|
||||
body Markdown, the whole note
|
||||
created_by nullable — survives the account being deleted
|
||||
pinned favourite; the list puts these first
|
||||
archived_at nullable timestamp — put away, not thrown away
|
||||
deleted_at nullable timestamp — the trash
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
### Markdown in a column
|
||||
|
||||
Not rich text, and not a format of our own.
|
||||
|
||||
Export was a requirement before a line was written, and **if the stored form is
|
||||
the exported form there is no conversion left to lose anything in.** A note stays
|
||||
readable by any editor on the day the program is gone. It is also what makes
|
||||
search and anything an AI does over notes cheap: both read text.
|
||||
|
||||
The cost is real and you should know it before choosing: everything the editor can
|
||||
do has to survive a round trip through Markdown, and anything Markdown cannot say,
|
||||
the editor must not offer. There is no place to hide a feature.
|
||||
|
||||
### The title is the first line
|
||||
|
||||
Always, and re-derived on every save — not "when it is empty".
|
||||
|
||||
Derived once and then left alone, a title goes stale the moment somebody rewrites
|
||||
the opening of a note, and the list ends up calling a note by a name that is
|
||||
nowhere in it. **The column is a cache of the body, not a field beside it**, and no
|
||||
caller can set it, so every caller gets the same answer.
|
||||
|
||||
This forces one editor rule: a new note opens on its title, and the first line is
|
||||
styled as a heading. Otherwise a note whose first line is a sentence is a note
|
||||
named by its first sentence.
|
||||
|
||||
### Archive and trash are two different bits
|
||||
|
||||
The trash says *this was a mistake, and in thirty days it is gone*. The archive
|
||||
says *this is finished, and I do not want to read past it.*
|
||||
|
||||
A twelve-hundred-note import is exactly the case where most notes are worth
|
||||
keeping and almost none are worth looking at. Without an archive, the list is the
|
||||
whole archive forever, and finding this week's note means reading past a decade.
|
||||
|
||||
Both are nullable timestamps rather than booleans: *when was this put away* answers
|
||||
questions a flag cannot, and it costs the same byte.
|
||||
|
||||
---
|
||||
|
||||
## Filing: no hierarchy of its own
|
||||
|
||||
A note belongs to a project, or to nobody.
|
||||
|
||||
The reasoning is worth repeating because it is easy to talk yourself out of:
|
||||
projects already exist, are already shared, already have roles, already have a
|
||||
trash. **A second hierarchy to file things in is one too many**, and a note filed
|
||||
under a project inherits every one of those answers instead of needing its own.
|
||||
|
||||
- A note with a project: everybody who can read the project can read the note; an
|
||||
editor can change it.
|
||||
- A note without one: the author's, and nobody else's.
|
||||
|
||||
Sharing a note *is* filing it in a project. There is no access list of its own —
|
||||
a second way to give somebody access is a second place to get it wrong, and the
|
||||
two would disagree the day one of them changed.
|
||||
|
||||
---
|
||||
|
||||
## Links: three kinds, parsed out of the text
|
||||
|
||||
The body is the truth. Links are an index over it, rebuilt on every save.
|
||||
|
||||
| Syntax | Means | Notes |
|
||||
|---|---|---|
|
||||
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
|
||||
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
|
||||
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
|
||||
|
||||
Patterns as used here:
|
||||
|
||||
```
|
||||
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
|
||||
note \[\[([^\]\n]{1,200})\]\]
|
||||
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
|
||||
```
|
||||
|
||||
Three decisions inside that table:
|
||||
|
||||
- **`#Firma` in a note means the same project as `#Firma` in the task box.** Not a
|
||||
second kind of tag that happens to look alike. That is the whole of "tag a note
|
||||
and see it in the project".
|
||||
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
|
||||
and the set is small; a diff is a second description of the same fact, and it
|
||||
can disagree with the text. That is the one thing this must never do.
|
||||
- **The target is not a foreign key.** A link to something deleted is a fact about
|
||||
the note. It should read as a dead link rather than vanish, and it must not
|
||||
block the delete.
|
||||
|
||||
The index exists so the question can be asked **backwards**: *what has been written
|
||||
about this task?* Answering that by searching every note's prose would be too slow
|
||||
to leave a panel open on.
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
Three layers, in the order they answer.
|
||||
|
||||
### 1. Full-text, ranked
|
||||
|
||||
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
|
||||
|
||||
Ranking is bm25 with **the title weighted ten times the body**. A word in the title
|
||||
is what the note is *about*; the same word once in a long body is a mention.
|
||||
|
||||
This matters more than it sounds. Ordering by "last touched" is invisible with ten
|
||||
notes and useless with twelve hundred imported the same evening: searching "Tesla"
|
||||
answered with a note about nginx first and the note actually called "Tesla Model Y"
|
||||
second — which reads as a search that does not work, because from where the person
|
||||
is sitting it is one.
|
||||
|
||||
### 2. Folding, for the language you are in
|
||||
|
||||
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
|
||||
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
|
||||
typing "gron", which is how people type when the keyboard is not theirs.
|
||||
|
||||
The answer here is a generated column carrying a transliterated copy:
|
||||
|
||||
```
|
||||
ø,Ø → o æ,Æ → ae å,Å → aa
|
||||
```
|
||||
|
||||
indexed alongside the body and **weighted the same as the body** — otherwise a
|
||||
folded match outranks an exact one.
|
||||
|
||||
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
|
||||
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
|
||||
> naive lowercasing entirely. Get the mapping from somebody who types the
|
||||
> language.
|
||||
|
||||
### 3. A word inside a word
|
||||
|
||||
An index is built of tokens, and a token is whatever the tokenizer decided was a
|
||||
word. `gamerule keepInventory true` is three of them, and the middle one is
|
||||
`keepinventory` — so a search for "keep inventory" asks for two words that are
|
||||
nowhere and gets nothing, while the note sits right there.
|
||||
|
||||
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
|
||||
are ANDed, so the half that works is thrown away by the half that cannot.
|
||||
|
||||
**This is not exotic in a notes app.** A decade of notes is full of commands,
|
||||
identifiers and file names, and every one of them is several words the index
|
||||
thinks is one.
|
||||
|
||||
So: the index answers first; when it has nothing, the text is read, and every term
|
||||
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
|
||||
column covers title, body and language folding at once.
|
||||
|
||||
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
|
||||
notes that is a few megabytes and a millisecond or two, and it only runs when the
|
||||
fast path already came back empty — which is exactly the search that was about to
|
||||
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
|
||||
answer then is a trigram index, not a bigger scan.
|
||||
|
||||
It does not rank. There is no match to rank — that is the whole situation.
|
||||
|
||||
---
|
||||
|
||||
## The list, and why the body is empty in it
|
||||
|
||||
A list showing a title and ninety characters of preview must not fetch whole
|
||||
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
|
||||
see.
|
||||
|
||||
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
|
||||
|
||||
A field called `body` holding a *piece* of the body is a trap. The editor opens
|
||||
it, the autosave pause writes it back, and the note is now cut down to its own
|
||||
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
|
||||
its own field and leave `body` empty; the client sees the empty body and fetches
|
||||
the whole note.
|
||||
|
||||
That costs one more separation, and it is not optional:
|
||||
|
||||
> **Which row is selected and which note is loaded are two different things.**
|
||||
|
||||
The editor loads its text when the note's id changes. Hand it the excerpt first
|
||||
and the whole note second and the id is the same both times — so the second one
|
||||
never renders, and somebody sits looking at a note that had a title a moment ago.
|
||||
|
||||
Highlight the row immediately from the list; give the editor the note only when it
|
||||
is whole.
|
||||
|
||||
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
|
||||
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
|
||||
virtual list, no scroll maths, and no list that can fall out of step with itself.
|
||||
Try that before building windowing.
|
||||
|
||||
---
|
||||
|
||||
## The editor contract
|
||||
|
||||
Rich text on top, Markdown underneath, and a source view that shows the file.
|
||||
|
||||
The supported subset — deliberately small, because everything in it has to survive
|
||||
the round trip:
|
||||
|
||||
| Block | Markdown |
|
||||
|---|---|
|
||||
| Title | `# ` |
|
||||
| Heading | `## ` |
|
||||
| Subheading | `### ` |
|
||||
| Quote | `> ` |
|
||||
| Code block | ```` ``` ```` with an optional language |
|
||||
| Bullet and numbered lists | `- `, `1. `, nested |
|
||||
|
||||
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
|
||||
and images as ``.
|
||||
|
||||
Five rules that were each learned the hard way:
|
||||
|
||||
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
|
||||
lines into a paragraph. Here it must not: the person typed a line and expects
|
||||
to get it back, and an editor that silently reflows what somebody wrote is an
|
||||
editor they stop trusting.
|
||||
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
|
||||
the caret back at the start, which mid-sentence is unusable.
|
||||
3. **One definition of what a code block contains.** A copy button inside the
|
||||
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
|
||||
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
|
||||
the code and the next save put it on disk. Have one function that says what a
|
||||
block holds, used by the highlighter and the Markdown serialiser both.
|
||||
4. **A note that cannot be rendered must not take the pane with it.** One note
|
||||
arriving in an unexpected shape threw inside the editor's reactive effect, and
|
||||
from then on *no* note would open — the previous one just stayed on screen. Put
|
||||
a guard around the render, fall back to showing the raw text, and let the rest
|
||||
of the pane keep working.
|
||||
5. **Escaping between tags and escaping inside an attribute are different jobs.**
|
||||
An escaper written for text between two tags handles `&`, `<`, `>` — and not
|
||||
quotes, because between tags they are harmless. Inside an attribute they close
|
||||
it. `` is the whole exploit.
|
||||
|
||||
---
|
||||
|
||||
## Attachments
|
||||
|
||||
Images pasted or dragged into a note, stored content-addressed by hash, referenced
|
||||
from the body as ordinary Markdown images.
|
||||
|
||||
The security shape, which is the part worth copying:
|
||||
|
||||
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
|
||||
are the one place a user supplies content another user opens. An uploaded SVG or
|
||||
HTML file rendered inline runs its own script on your origin with the session
|
||||
cookie attached.
|
||||
- **One exception, as a named allowlist:** a short list of raster image types
|
||||
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
|
||||
A note carries its pictures in the text, and served as octet-stream every one of
|
||||
them is a broken image.
|
||||
- **The list is named one type at a time, never matched on an `image/` prefix.**
|
||||
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
|
||||
script.
|
||||
- **The stored path is checked against the files directory again at read time.** A
|
||||
traversal that somehow got into the database must not become one that reads
|
||||
`/etc/passwd`.
|
||||
- **Reaching an attachment follows the note, which follows its project.** Same
|
||||
chain as everything else. Do not let a client hand you the parent id and take it
|
||||
on trust.
|
||||
|
||||
---
|
||||
|
||||
## Import and export
|
||||
|
||||
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
|
||||
front matter block.
|
||||
|
||||
```markdown
|
||||
---
|
||||
created: 2019-04-02T10:15:00Z
|
||||
modified: 2024-11-30T21:04:00Z
|
||||
---
|
||||
|
||||
# Møde med Anders
|
||||
|
||||
Han vil gerne have kaffe hver uge.
|
||||
```
|
||||
|
||||
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
|
||||
all accepted on the way in. Obsidian and Bear write this block; so should you.
|
||||
- Images travel in the zip beside the notes, and the links in the text are
|
||||
relative — **a note and its pictures must end up in the same archive**, which
|
||||
matters the moment an export is split into parts.
|
||||
- File names collide: two notes called "Møde" are two notes, and a zip with one
|
||||
entry twice is a zip that loses one.
|
||||
|
||||
Limits that a real import taught, all of them cheap to add and expensive to omit:
|
||||
|
||||
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
|
||||
64 MB of zip expanded to 66 GB is a normal shape for an attack.
|
||||
- A per-file limit as well as a whole-archive budget: one enormous photograph and
|
||||
ten thousand small ones are different attacks with the same ending.
|
||||
- The entry name is never used as a path. Store by hash; the name is only a key
|
||||
for rewriting links.
|
||||
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
|
||||
dropped silently by a filter looking for `..` — one out of 1141, found only by
|
||||
counting at both ends.
|
||||
|
||||
### Timestamps on the way in
|
||||
|
||||
An importer needs to write `created_at` and `updated_at` from the file, and the
|
||||
normal save path must refuse to let it.
|
||||
|
||||
`updated_at` means *when did this program last write this note*. Let any caller set
|
||||
it and the column means whatever the last writer felt like. Give the importer its
|
||||
own narrow entry point instead, and say in the comment why it exists.
|
||||
|
||||
Without those dates, a move of twelve hundred notes is a pile that was all written
|
||||
the same evening — and the order they were written in is half of what an archive
|
||||
is.
|
||||
|
||||
---
|
||||
|
||||
## What is still open here
|
||||
|
||||
Written down because a specification that only lists what works is a specification
|
||||
that lies by omission.
|
||||
|
||||
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
|
||||
they are in every backup anybody can download. Encrypting them is bigger than it
|
||||
sounds: the body is in the full-text index and the folded column is generated
|
||||
from it, so both are a second plaintext copy in the same database. Encrypt and
|
||||
both have to go, and search has to be rewritten to decrypt in application code —
|
||||
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
|
||||
point the answer is a keyed index (an HMAC per word).
|
||||
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
|
||||
- **There is no sync.** Export and the API are the way out.
|
||||
|
||||
---
|
||||
|
||||
## A short list of the traps, for the person in a hurry
|
||||
|
||||
1. A `body` field holding an excerpt will be saved over the real one.
|
||||
2. Selected row and loaded note are two different pieces of state.
|
||||
3. The title must be re-derived on every save or it goes stale.
|
||||
4. Diacritic stripping is not folding, and folding is language-specific.
|
||||
5. A tokenizer cannot see a word inside a word; decide what you do about it.
|
||||
6. An attachment served inline is a script on your origin unless you allowlist by
|
||||
exact type and send `nosniff`.
|
||||
7. Escaping for text and escaping for attributes are different functions.
|
||||
8. One render failure must not be allowed to freeze the whole pane.
|
||||
9. An unpacked-size limit is not optional on an archive import.
|
||||
10. Let the importer set timestamps; let nobody else.
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
|
||||
integrations that a hosted product will not build for you: a real CalDAV server, a
|
||||
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data, and with the integrations that a
|
||||
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
|
||||
and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -35,6 +36,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
||||
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||
views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
@@ -90,21 +96,30 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Project templates | Done, tested |
|
||||
| Nightly backups with rotation | Done, tested |
|
||||
| Comments, attachments, notifications, Web Push | Built |
|
||||
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
|
||||
| Notes in and out: a zip of Markdown, both directions | Done, tested |
|
||||
| Todoist CSV import and export, account export | Done, tested |
|
||||
| MCP server for Claude | Done, tested |
|
||||
| CalDAV server (two-way VTODO) | Done, tested |
|
||||
| Mail-to-task | Done, tested |
|
||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
|
||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||
| Notes: archive, grouped list, images, source view | Done, tested |
|
||||
| Update notice for administrators | Done, tested |
|
||||
| Personal API tokens | Done, tested |
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
|
||||
**Not finished:** Web Push, the AI providers and the Gmail API calls are
|
||||
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
not against live services. Everything else has an interface: opening a task shows
|
||||
not against live services. Gmail is no longer in that list — it runs in production,
|
||||
including the flagging and de-duplication that took a day to get right. Calendar
|
||||
subscriptions over ICS and webcal are proven; the Google half of the calendar is
|
||||
not. Everything else has an interface: opening a task shows
|
||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||
templates.
|
||||
@@ -116,17 +131,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
| Board view — one column per section. | The light theme. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
|
||||
|  |  |
|
||||
| Five themes, all measured for contrast. | What has been done on the server. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
|
||||
|  |  |
|
||||
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
|
||||
|  |  |
|
||||
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
|
||||
|
||||
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
|
||||
the real binary, creates its own account, fills it with demo data and photographs
|
||||
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
|
||||
still works, and is the way to photograph an instance that is already running.)
|
||||
the real binary, creates its own account and a second one to share with, fills them
|
||||
with demo data and photographs fourteen surfaces — nothing to set up and nothing to
|
||||
remember. (`go run ./tools/shots` still works, and is the way to photograph an
|
||||
instance that is already running.)
|
||||
|
||||
## Running it
|
||||
|
||||
|
||||
@@ -10,13 +10,28 @@ reply within a week.
|
||||
|
||||
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
|
||||
be raised later without locking anyone out.
|
||||
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
|
||||
stored only as a SHA-256 hash. A database dump contains nothing that can be
|
||||
presented.
|
||||
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
|
||||
database dump contains nothing that can be presented.
|
||||
- **The calendar feed address and the mail-to-task address are not hashed.** They
|
||||
are looked up by their exact value, and they are shown in the interface more
|
||||
than once, which a hash cannot do. Anybody holding a backup file therefore holds
|
||||
those two URLs: the feed reads that person's tasks without a login, and the mail
|
||||
address files tasks into their inbox. Rotating either from **Settings →
|
||||
Integrations** invalidates the old one. This is written down rather than fixed
|
||||
because fixing it means the address can only ever be shown once, which is a
|
||||
decision about how the feature works and not only about how it is stored.
|
||||
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
|
||||
AES-GCM under a key kept outside the database — see `internal/secret`. That is
|
||||
what makes a stray copy of the database less than a stray copy of everything:
|
||||
the backup does not carry the key.
|
||||
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
|
||||
- **Uploads** are stored content-addressed by hash and always served as
|
||||
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
|
||||
on this origin.
|
||||
- **Uploads** are stored content-addressed by hash and served as
|
||||
`application/octet-stream` with `nosniff` — with one exception, added so that a
|
||||
note can show the pictures in it: a short allowlist of raster image types is
|
||||
served inline as its own type. The list is named one format at a time rather
|
||||
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
|
||||
through, and an SVG is a document that can run script. `nosniff` stays, so the
|
||||
browser must read the file as that image or not at all.
|
||||
- **The filter language** compiles to parameterised SQL. No typed text is ever
|
||||
concatenated into a statement.
|
||||
- **Permission checks** are route middleware, and anything you may not see answers
|
||||
@@ -24,8 +39,28 @@ reply within a week.
|
||||
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
||||
page, rather than allowing `unsafe-inline`.
|
||||
|
||||
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
|
||||
only the body: a note's title *is* its first line, so a readable title beside a
|
||||
sealed body would leave the beginning of every note in the clear.
|
||||
|
||||
The two plaintext copies went with it. `notes_fts` indexed the body and the
|
||||
generated `fold` column held a transliterated copy of it — sealing the body while
|
||||
leaving those would have been theatre, since a stolen file could be read straight
|
||||
out of the index. Search opens the text in Go instead, which costs 33–43 ms
|
||||
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
|
||||
- **Task titles, descriptions and comments are still in the clear**, and so is
|
||||
`note_links` — ids derived from a note's body, so a stolen file still shows which
|
||||
projects and tasks a note points at, though not what it says.
|
||||
|
||||
## What it does not do
|
||||
|
||||
verdande has no rate limiting beyond the login and password-reset endpoints, and
|
||||
no audit log of reads. It is built to be run behind something — a Cloudflare
|
||||
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
|
||||
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
|
||||
password, the second factor, the installation beacon, and inbound mail. **Nothing
|
||||
else is limited** — an authenticated caller can ask as often as they like, and a
|
||||
signed-in account that wanted to be a nuisance can be one.
|
||||
|
||||
There is no audit log of reads. Who *changed* something is recorded per project;
|
||||
who looked at it is not.
|
||||
|
||||
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
|
||||
VPN — by somebody who knows what they exposed.
|
||||
|
||||
@@ -64,6 +64,32 @@ func run() error {
|
||||
}
|
||||
db.UseSecrets(box)
|
||||
|
||||
// Noter, der stadig ligger åbne, lukkes nu.
|
||||
//
|
||||
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
|
||||
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
|
||||
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
|
||||
// så den koster et gennemløb af id'er hver anden gang.
|
||||
//
|
||||
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
|
||||
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
|
||||
// er værre end en, der siger fra.
|
||||
if sealed, err := db.SealNotes(context.Background()); err != nil {
|
||||
return fmt.Errorf("seal notes: %w", err)
|
||||
} else if sealed > 0 {
|
||||
log.Info("notes sealed", "count", sealed)
|
||||
}
|
||||
|
||||
// Names created before the app capitalised them on the way in — "andreas"
|
||||
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
||||
// caught up. A failure here does not stop the start: a lower-case first name is
|
||||
// a blemish, not a broken instance.
|
||||
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
||||
log.Error("normalize user names", "err", err)
|
||||
} else if fixed > 0 {
|
||||
log.Info("user names normalized", "count", fixed)
|
||||
}
|
||||
|
||||
httpapi.Version = version
|
||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||
|
||||
@@ -75,7 +101,9 @@ func run() error {
|
||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Calendar and CalDAV
|
||||
|
||||
Two ways to get your tasks into a calendar, and they are not the same thing.
|
||||
Two ways to get your tasks into a calendar. Getting somebody else's calendar
|
||||
*into* verdande is the other direction and has its own page — see
|
||||
[Calendar](calendar.md).
|
||||
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|
||||
|---|---|---|
|
||||
| Direction | Read only | Two-way |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
|
||||
| You can tick things off | No | Yes |
|
||||
| Setup | Paste a URL | Username and token |
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Calendars in](calendar.md) |
|
||||
|---|---|---|---|
|
||||
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||
| You can tick things off | No | Yes | No — an event is not a task |
|
||||
| Setup | Paste a URL | Username and token | Connect, then pick calendars |
|
||||
|
||||
## Subscribing to a feed
|
||||
|
||||
@@ -39,6 +41,13 @@ most people actually keep.
|
||||
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
|
||||
immediately.
|
||||
|
||||
## Showing a Google calendar in verdande
|
||||
|
||||
Moved. Connecting a Google account, subscribing to an address, and what the grid
|
||||
does with either now live on their own page — see [Calendar](calendar.md).
|
||||
|
||||
This page is the other direction: verdande's own tasks, read by something else.
|
||||
|
||||
## Connecting a CalDAV client
|
||||
|
||||
This is the one where ticking something off in Apple Reminders ticks it off here.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Calendar
|
||||
|
||||
Your dated tasks as a grid, with other people's calendars laid under them.
|
||||
|
||||
**Calendar** in the menu shows a month or a week. The week is a day: hours down the
|
||||
side, and everything with a time on it in its place. Two things at ten o'clock sit
|
||||
beside each other, because that is a clash you should be able to see rather than
|
||||
read.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Your tasks**, on the day they are due — and at the time, if they have one.
|
||||
- **Events from calendars you have connected**, read-only.
|
||||
|
||||
Drag a task to another day to move it. Drop it on the hour column and it takes that
|
||||
time; drop it in the band across the top and it keeps the day and loses the time.
|
||||
Events cannot be dragged: verdande holds a copy of somebody else's calendar, and a
|
||||
chip that lets itself be moved is a promise the server cannot keep.
|
||||
|
||||
A project's own calendar is under **Calendar** on the project page, with the same
|
||||
week and month.
|
||||
|
||||
## Subscribing to a calendar
|
||||
|
||||
**Settings → Integrations → Subscriptions.** Paste an address and it is fetched at
|
||||
once.
|
||||
|
||||
This works with anything that publishes an `.ics` file: public holidays, a club's
|
||||
fixtures, a shared calendar from work, your own calendar's secret address from
|
||||
Google, Apple or Fastmail.
|
||||
|
||||
- **`webcal://` addresses work.** They are the same URL over https with a scheme
|
||||
that makes a desktop hand it to the calendar app; it is rewritten for you.
|
||||
- **Several at a time.** Unlike the Google connection, which is one account, a
|
||||
subscription is one address and you can have as many as you like.
|
||||
- **Read-only, refreshed every fifteen minutes.**
|
||||
- The address is fetched once before it is stored, so a typo tells you now rather
|
||||
than by silently never filling.
|
||||
|
||||
!!! note "This is the way in for a private account"
|
||||
If the instance's Google registration is *Internal*, Google refuses accounts
|
||||
from outside the organisation with `org_internal` — no setting on your side
|
||||
changes that. Your calendar's own secret ICS address goes through this panel
|
||||
and works regardless.
|
||||
|
||||
## Connecting Google Calendar
|
||||
|
||||
**Settings → Integrations → Google Calendar.** It shares the OAuth client Gmail
|
||||
uses, so the instance needs that registration first — see
|
||||
[Configuration](configuration.md#google).
|
||||
|
||||
Two things have to be true in Google Cloud, and only the owner of the registration
|
||||
can do them:
|
||||
|
||||
1. **Google Calendar API** is enabled for the project.
|
||||
2. The calendar callback is listed as an authorised redirect URI. The exact string
|
||||
is printed in the panel — copy it from there rather than typing it. Google
|
||||
compares it exactly: scheme, host, path, no trailing slash. `redirect_uri_mismatch`
|
||||
almost always means one of those four differs.
|
||||
|
||||
Once connected, tick the calendars you want to see. The list is refreshed on every
|
||||
sync, so a calendar added or renamed since last time appears under its new name.
|
||||
|
||||
!!! warning "Written to the specification, not yet proven against Google"
|
||||
The Google Calendar half is implemented from Google's documentation and tested
|
||||
against a stand-in server. It has not been exercised against Google itself.
|
||||
The subscription route above and the outgoing feed below have.
|
||||
|
||||
## Your own calendar, elsewhere
|
||||
|
||||
Two ways out, for two different jobs:
|
||||
|
||||
- **[The ICS feed](caldav.md#subscribing-to-a-feed)** — a read-only address for Apple Calendar,
|
||||
Google or Thunderbird. Your tasks appear as dated entries.
|
||||
- **[CalDAV](caldav.md)** — two-way. Tick something off in Apple Reminders and it
|
||||
is ticked off here.
|
||||
|
||||
The feed address is a credential in itself: anybody holding it can read your tasks
|
||||
without logging in, because a calendar client cannot sign in. Rotate it from
|
||||
**Settings → Integrations** if it gets out.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **It does not write to a connected calendar.** Events are a copy.
|
||||
- **It does not expand repeating events** from a subscribed file. A weekly meeting
|
||||
in somebody else's calendar is stored as its first occurrence.
|
||||
- **It does not resolve named time zones** from a subscribed file. An event carrying
|
||||
`TZID=Europe/Copenhagen` shows the clock the file wrote, rather than one this
|
||||
server has moved.
|
||||
- **Only Google, and only reading**, for account connections. Everything else goes
|
||||
through a subscription.
|
||||
@@ -12,6 +12,25 @@ nowhere to put one that the operator would ever see.
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
verdande keys its login rate limit and its audit log on the caller's address, so
|
||||
that address has to be one the caller cannot forge. A forwarded-for header is
|
||||
believed only when the machine that connected is a trusted proxy; a connection
|
||||
straight off the internet is keyed on the address it really came from.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
|
||||
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
|
||||
|
||||
!!! warning "A proxy that does not overwrite the header"
|
||||
The default is safe when your proxy replaces a client-supplied
|
||||
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
|
||||
do. If yours passes the client's own header through, narrow
|
||||
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
|
||||
choose the address they are rate-limited and logged as.
|
||||
|
||||
## Sessions and links
|
||||
|
||||
| Variable | Default | What it does |
|
||||
@@ -39,12 +58,24 @@ Any Go duration: `720h`, `30m`, `45s`.
|
||||
yourself, password resets are written to the log, and reminders arrive in the
|
||||
app and as Web Push rather than by email.
|
||||
|
||||
## Gmail
|
||||
## Google
|
||||
|
||||
Only needed if anyone will connect a mailbox. Register an OAuth client in Google
|
||||
Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with
|
||||
the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API,
|
||||
and add the `gmail.readonly` scope.
|
||||
Only needed if anyone will connect a Gmail mailbox or a Google calendar. One
|
||||
registration covers both: register an OAuth client in Google Cloud
|
||||
(**APIs & Services → Credentials → OAuth client ID → Web application**) and add
|
||||
**both** redirect URIs:
|
||||
|
||||
```
|
||||
https://your-address/oauth/gmail/callback
|
||||
https://your-address/oauth/calendar/callback
|
||||
```
|
||||
|
||||
Then enable the APIs and scopes for whichever you want:
|
||||
|
||||
| For | Enable | Scope |
|
||||
|---|---|---|
|
||||
| Mailboxes | Gmail API | `gmail.modify` |
|
||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||
|
||||
| Variable | What it does |
|
||||
|---|---|
|
||||
@@ -52,8 +83,15 @@ and add the `gmail.readonly` scope.
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
|
||||
|
||||
One registration serves every user on the instance; each person authorises their
|
||||
own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
||||
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
|
||||
own mailbox from **Settings → Integrations → Gmail** and their own calendar from
|
||||
**Settings → Integrations → Google Calendar**. Both redirect URIs are derived from
|
||||
`VERDANDE_BASE_URL`, so they cannot drift out of step with what you registered.
|
||||
|
||||
!!! note "Two connections, two grants"
|
||||
Connecting a calendar does not disturb a working Gmail connection, and
|
||||
disconnecting one does not take the other with it. Google issues a refresh
|
||||
token per authorisation and keeps up to a hundred of them live per account,
|
||||
each carrying only the scopes it was granted with.
|
||||
|
||||
## Secrets
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Saved filters
|
||||
|
||||
A filter is a small expression describing a slice of your tasks. The grammar is
|
||||
Todoist's, so if you are coming from there you already know it.
|
||||
A filter is a small expression describing a slice of your tasks. The grammar will
|
||||
feel familiar if you have used query-based filters in other task apps.
|
||||
|
||||
```
|
||||
today & p1
|
||||
|
||||
@@ -11,7 +11,8 @@ of. Which is what a to-do list is.
|
||||
|
||||
## What it is for
|
||||
|
||||
Todoist Pro, on your own hardware, with your own data. And with the integrations a
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data. And with the integrations a
|
||||
hosted product will not build for you: a real CalDAV server, an address you can
|
||||
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
@@ -22,15 +23,54 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
to move it.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||
single note with a person, or file it in a project; `#project` and
|
||||
`[[another note]]` are links, and a task shows what has been written about it.
|
||||
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
||||
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
||||
greyed, at the foot of the list until a moment you pick — without touching when
|
||||
it is due.
|
||||
- **Drag where it means something** — reorder a list or a board, file a project
|
||||
under a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||
joins through an invite link.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **Repeating tasks** as RRULE, reminders, comments and attachments.
|
||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||
out as Web Push to an iPhone or a Mac.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
|
||||
## What it looks like
|
||||
|
||||

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

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

|
||||
|
||||
*What you have handed to somebody else, grouped by whoever has it — and each row
|
||||
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
||||
meet it.*
|
||||
|
||||

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

|
||||
|
||||
*Inviting somebody to the instance. There is no open registration — everybody
|
||||
arrives through a link, and picks their own password. See
|
||||
[signing in](signing-in.md).*
|
||||
|
||||
## Getting it running
|
||||
|
||||
verdande is built to be deployed as a **Rune** in
|
||||
|
||||
@@ -16,7 +16,7 @@ administrator who can read yours.
|
||||
**Settings → Integrations → Gmail → Connect.**
|
||||
|
||||
Gmail needs an OAuth client, which is the instance's registration with Google
|
||||
rather than anybody's account — see [Configuration](configuration.md#gmail).
|
||||
rather than anybody's account — see [Configuration](configuration.md#google).
|
||||
Once it is set up, each person connects their own mailbox through it.
|
||||
|
||||
Choose what makes a task:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Notes
|
||||
|
||||
Markdown in, Markdown out. Nothing in between.
|
||||
|
||||
A note is written as rich text — headings, lists, quotations, code — and stored as
|
||||
the Markdown it looks like. That is not a detail about the database: it is what
|
||||
makes a note yours. Export gives you the same text you were looking at, and on the
|
||||
day this program is gone, every note opens in any editor.
|
||||
|
||||
## Writing
|
||||
|
||||
Start typing. **The first line is the title** — always, and re-derived every time
|
||||
you save, so a note you rewrite the top of is called by what it now says rather
|
||||
than by what it once did.
|
||||
|
||||
| Style | Written as | Shortcut |
|
||||
|---|---|---|
|
||||
| Title | `# ` at the start of the line | pick it in **Aa** |
|
||||
| Heading | `## ` | |
|
||||
| Subheading | `### ` | |
|
||||
| Quote | `> ` | |
|
||||
| Code block | three backticks, with an optional language | |
|
||||
| Bullet and numbered lists | `- ` and `1. `, nested with Tab | |
|
||||
| Bold | two asterisks around the words | ⌘B |
|
||||
| Italic | one asterisk around the words | ⌘I |
|
||||
| Underline | a `u` tag around the words | ⌘U |
|
||||
| Strikethrough | two tildes around the words | |
|
||||
| Code | one backtick around the words | |
|
||||
|
||||
Every line becomes exactly one block. Markdown would normally join consecutive
|
||||
lines into a paragraph; here it does not, because you typed a line and expect to
|
||||
get it back.
|
||||
|
||||
Press the source button in the toolbar to see the note as the file it is. Edits there are edits to the
|
||||
note.
|
||||
|
||||
### Pictures
|
||||
|
||||
Paste one, or drag it onto the note. It is stored with the note and travels with
|
||||
it in an export. Every raster format works; SVG is stored but not shown inline —
|
||||
it is a document that can run script, and a note is not a place to run somebody
|
||||
else's.
|
||||
|
||||
### Code blocks
|
||||
|
||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||
Each one carries a **Copy** button in its corner.
|
||||
|
||||
## Linking
|
||||
|
||||
Three kinds of reference, parsed out of the text as you write it:
|
||||
|
||||
| You type | It means |
|
||||
|---|---|
|
||||
| `#Firma` | The project — the same `#Firma` quick add understands, not a second kind of tag |
|
||||
| `[[Møde med Anders]]` | Another note, by its title |
|
||||
| A pasted task link | That task |
|
||||
|
||||
Links are an index over the text, rebuilt every time the note is saved. The text
|
||||
stays the truth.
|
||||
|
||||
**The interesting direction is backwards.** A task shows what has been written
|
||||
about it, and a project shows the notes filed in it — without anybody having to
|
||||
remember to attach anything. Write `#Firma` in a note and it appears on that
|
||||
project's page.
|
||||
|
||||
A link to something that has been deleted reads as a dead link. It is a fact about
|
||||
the note, and it should not vanish because something it mentioned did.
|
||||
|
||||
## Filing and sharing
|
||||
|
||||
A note belongs to a project, or to nobody.
|
||||
|
||||
- **In a project:** everybody who can read the project can read the note, and an
|
||||
editor can change it. Choose it under **Share in** at the foot of the note.
|
||||
- **In no project:** yours, and nobody else's.
|
||||
|
||||
There is no folder tree. Projects, groups and labels already exist, are already
|
||||
shared and already have roles — a second hierarchy to file things in is one too
|
||||
many, and a note in a project inherits every one of those answers.
|
||||
|
||||
## Finding
|
||||
|
||||
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
||||
week**, and then by month. Sort by created, last touched or name, and the grouping
|
||||
follows — sorted by name it groups by first letter instead.
|
||||
|
||||
Search finds a word wherever it is:
|
||||
|
||||
- **`gron` finds `grøn`.** Unicode does not consider `ø` an accented `o`, so
|
||||
stripping diacritics is not enough for Danish. A folded copy of every note is
|
||||
indexed beside the original.
|
||||
- **`inventory` finds `keepInventory`.** An index is built of whole words, and a
|
||||
decade of notes is full of commands and identifiers that are several words in
|
||||
one. When the index has nothing, the text itself is read.
|
||||
- Results are ranked by how well they match, with the title weighted ten times the
|
||||
body. A word in the title is what the note is about; the same word once in a long
|
||||
body is a mention.
|
||||
|
||||
## Putting notes away
|
||||
|
||||
Two different things, and the difference matters:
|
||||
|
||||
- **Archive** — this is finished and I do not want to read past it. It stays
|
||||
findable and comes back whenever you ask.
|
||||
- **Trash** — this was a mistake. It is gone in thirty days.
|
||||
|
||||
Select several with ⌘-click and shift-click to archive or delete them together.
|
||||
|
||||
## In and out
|
||||
|
||||
**Export** gives you a zip of Markdown files, one per note, with the dates in a
|
||||
YAML front matter block that Obsidian and Bear also read:
|
||||
|
||||
```markdown
|
||||
---
|
||||
created: 2019-04-02T10:15:00Z
|
||||
modified: 2024-11-30T21:04:00Z
|
||||
---
|
||||
|
||||
# Møde med Anders
|
||||
|
||||
Han vil gerne have kaffe hver uge.
|
||||
```
|
||||
|
||||
**Import** takes the same shape back. Pictures travel in the zip beside the notes,
|
||||
and the links in the text are relative — so a note and its pictures must be in the
|
||||
same archive, which matters when a large export is split into parts.
|
||||
|
||||
Two things to know before importing:
|
||||
|
||||
- **Nothing is merged.** Every run creates new notes. Importing the same archive
|
||||
twice gives you two of everything.
|
||||
- **The dates are read from the front matter.** Without them, a move of a thousand
|
||||
notes is a pile that was all written the same evening — and the order they were
|
||||
written in is half of what an archive is.
|
||||
|
||||
### From Apple Notes
|
||||
|
||||
`tools/apple-notes-til-markdown.py` in the repository exports Apple Notes to
|
||||
exactly this shape, front matter and pictures included. It is macOS-only, because
|
||||
Apple Notes is.
|
||||
|
||||
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
|
||||
uploadable parts, keeping each note with its own pictures.
|
||||
|
||||
## What is encrypted, and what is not
|
||||
|
||||
**A note's title and body are sealed** in the database, under a key kept in a file
|
||||
beside it rather than inside it. A backup you download does not carry the key, so
|
||||
it does not carry your notes.
|
||||
|
||||
Both halves, not only the body: a note's title is its first line, so a readable
|
||||
title beside a sealed body would leave the beginning of every note in the open.
|
||||
|
||||
**What is still in the clear:** task titles, descriptions and comments — and the
|
||||
list of what a note links to, so a stolen file shows which projects and tasks a
|
||||
note points at, though not what it says.
|
||||
|
||||
Sealing the notes cost the search its index — a full-text index over a note's body
|
||||
is another copy of that body — so search reads the notes themselves now. At twelve
|
||||
hundred notes that is about forty milliseconds, and the box waits a quarter of a
|
||||
second after you stop typing, so you will not see it.
|
||||
@@ -805,6 +805,28 @@ paths:
|
||||
"200": { $ref: "#/components/responses/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/tasks/{taskID}/snooze:
|
||||
parameters:
|
||||
- { name: taskID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [tasks]
|
||||
summary: Snooze a task until a time, or wake it.
|
||||
description: |
|
||||
Parks the task at the bottom of its list, greyed, until `until` passes —
|
||||
nothing else about it moves. An empty `until` wakes it now.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Task" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/tasks/quick-add:
|
||||
post:
|
||||
tags: [tasks]
|
||||
@@ -1228,7 +1250,7 @@ paths:
|
||||
|
||||
The one click that opens Google's consent screen already exists. What it
|
||||
needs behind it is a registered client, and no app can conjure one: Google
|
||||
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
|
||||
issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||
restricted scope, so an id shipped inside a public image would be both
|
||||
extractable from it and unusable without Google's security review of every
|
||||
deployment that used it.
|
||||
@@ -1396,6 +1418,204 @@ paths:
|
||||
description: The environment variables that are not set.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/notes/bulk:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Archive, unarchive or delete a set of notes in one call.
|
||||
description: |
|
||||
One call rather than one per note, because the thing being asked is one
|
||||
thing: "put these away". Fifty separate requests would be fifty round
|
||||
trips, fifty chances for half of them to fail, and a list that redraws
|
||||
fifty times — and after an import of twelve hundred notes, selecting fifty
|
||||
is the ordinary case rather than the extreme one.
|
||||
|
||||
Each note is still checked on its own. A set is a convenience for the
|
||||
caller and never a way to touch something they could not have touched one
|
||||
at a time; anything they may not write to is counted in `skipped` rather
|
||||
than failing the whole call, so one note somebody else just deleted does
|
||||
not stop the other forty-nine.
|
||||
|
||||
Archiving is not deleting. The trash says "this was a mistake, and in
|
||||
thirty days it is gone"; the archive says "this is finished, and I want to
|
||||
keep it".
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: At most 500.
|
||||
archived:
|
||||
type: boolean
|
||||
description: Put away, or bring back out.
|
||||
delete:
|
||||
type: boolean
|
||||
description: Into the trash instead. Wins over `archived`.
|
||||
responses:
|
||||
"200":
|
||||
description: How many were changed, and how many were passed over.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
done: { type: integer }
|
||||
skipped: { type: integer }
|
||||
"422":
|
||||
description: No ids, too many, or neither archived nor delete was asked for.
|
||||
|
||||
/api/v1/notes/{noteID}/attachments:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Put a file into a note.
|
||||
description: |
|
||||
A picture pasted or dropped into a note. Until this existed the import
|
||||
could bring files in and nothing else could, so the most ordinary act in
|
||||
a notes app — paste a screenshot — did nothing at all.
|
||||
|
||||
Editor rights on the note. The same answer for "no such note" and "not
|
||||
yours", so an id cannot be probed.
|
||||
|
||||
The response carries the attachment's URL; the editor writes that into the
|
||||
note's text as a Markdown image. The file is never inlined as a data URL —
|
||||
a photograph as base64 would make the note itself megabytes of text.
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
"201":
|
||||
description: Stored, and hung on the note.
|
||||
"404":
|
||||
description: No such note, or not yours to write in.
|
||||
"413":
|
||||
description: Larger than 25 MB.
|
||||
|
||||
/api/v1/beacon:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Report that an installation exists. Unauthenticated.
|
||||
description: |
|
||||
The receiving end of the beacon. Open on purpose: the caller is a
|
||||
stranger's installation, which is the whole feature.
|
||||
|
||||
Answers 404 unless this instance has been told it is the collector, so an
|
||||
ordinary installation is not a spam sink that answers 200 to anything.
|
||||
Rate limited like the login routes, and the table behind it has a ceiling:
|
||||
the endpoint inserts a row keyed on an id the caller invented, so without
|
||||
one anybody could grow it until the volume is full.
|
||||
|
||||
Nothing about the request is recorded but the two values in the body. No
|
||||
source address is read or written here.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [instance_id]
|
||||
properties:
|
||||
instance_id:
|
||||
type: string
|
||||
description: A random id the installation generated for itself.
|
||||
version:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: Recorded.
|
||||
"400":
|
||||
description: The id or the version is not the expected shape.
|
||||
"404":
|
||||
description: This instance is not a collector.
|
||||
|
||||
/api/v1/beacon/count:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: How many installations reported in the last thirty days.
|
||||
description: |
|
||||
Unauthenticated and cached for a minute, and only answered when the
|
||||
collector has been told to publish it.
|
||||
|
||||
A window rather than a running total: a total only climbs, so it stops
|
||||
meaning "how many people use this" and starts meaning "how many ever
|
||||
tried it".
|
||||
responses:
|
||||
"200":
|
||||
description: The count.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
installs: { type: integer }
|
||||
window_days: { type: integer }
|
||||
"404":
|
||||
description: Not a collector, or not published.
|
||||
|
||||
/api/v1/beacon/settings:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: What this instance reports, and to whom. Administrators only.
|
||||
description: |
|
||||
Includes the exact payload that will be sent, so the settings page can
|
||||
print it in full rather than describe it.
|
||||
responses:
|
||||
"200":
|
||||
description: The current configuration, and the counts if this is the collector.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
collector_url: { type: string }
|
||||
instance_id: { type: string }
|
||||
version: { type: string }
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
last_ping_at: { type: string, format: date-time }
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: Turn the beacon off, or point it somewhere else. Administrators only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
collector_url:
|
||||
type: string
|
||||
description: |
|
||||
Where reports go. Fetched through a dialer that refuses
|
||||
loopback and private address space, so this cannot be used to
|
||||
make the server reach inside its own network.
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: The configuration as it now stands.
|
||||
"422":
|
||||
description: The collector address is not an http or https URL.
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
@@ -1851,6 +2071,125 @@ paths:
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/{projectID}/sections/reorder:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Set the order the project's sections appear in. Editors and owners.
|
||||
description: |
|
||||
The whole list at once, for the same reasons as `/projects/reorder`: a
|
||||
project has a handful of sections, so sending the order you want is
|
||||
simpler than midpoints and cannot land half-applied.
|
||||
|
||||
Ids belonging to another project are skipped rather than refused.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items: { type: string }
|
||||
description: Section ids, top to bottom.
|
||||
responses:
|
||||
"204": { description: Reordered. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/calendar/subscriptions:
|
||||
post:
|
||||
tags: [calendar]
|
||||
summary: Subscribe to a calendar you only have an address to.
|
||||
description: |
|
||||
An ICS or `webcal://` address. Several per person, unlike the Google
|
||||
connection, which is one — the address is the identity here, the way the
|
||||
token is for OAuth.
|
||||
|
||||
The address is fetched once before it is stored: an address that cannot be
|
||||
read is a typo far more often than a server that happens to be down, and
|
||||
saying so while somebody is still looking at the field is worth two
|
||||
seconds. `webcal://` is rewritten to `https://`, and the destination is
|
||||
checked against the private-network wall both when it is written down and
|
||||
again on the resolved address every time it is fetched.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
url: { type: string, description: "https:// or webcal://" }
|
||||
name:
|
||||
type: string
|
||||
description: Optional. The calendar's own name is used when this is empty.
|
||||
responses:
|
||||
"201":
|
||||
description: Subscribed, and fetched once.
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/calendar/subscriptions/{id}:
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
delete:
|
||||
tags: [calendar]
|
||||
summary: Stop subscribing. The cached events go with it.
|
||||
responses:
|
||||
"204": { description: Gone. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/push/test:
|
||||
post:
|
||||
tags: [notifications]
|
||||
summary: Send one notification to your own devices, and say what happened.
|
||||
description: |
|
||||
The answer to "no notifications arrive". A push that a service refuses is
|
||||
the ordinary failure — a stale subscription, a browser that revoked
|
||||
permission without telling the server — and every one of them looks
|
||||
exactly like silence from the outside.
|
||||
|
||||
It goes through the same path a real notification takes, so what it proves
|
||||
is the thing that has to work. The title and body come from the caller
|
||||
because the dictionaries live in the interface.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title: { type: string, maxLength: 200 }
|
||||
body: { type: string, maxLength: 500 }
|
||||
responses:
|
||||
"200":
|
||||
description: What each device did with it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
subscriptions:
|
||||
type: integer
|
||||
description: Devices registered for this account.
|
||||
sent:
|
||||
type: integer
|
||||
failed:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
service:
|
||||
type: string
|
||||
description: The push service's host — never the endpoint, which is a credential.
|
||||
reason:
|
||||
type: string
|
||||
description: What the service said.
|
||||
|
||||
/api/v1/sections/{sectionID}:
|
||||
parameters:
|
||||
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
||||
@@ -2507,6 +2846,126 @@ paths:
|
||||
created: { type: integer }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The Google Calendar connection and the calendars in it.
|
||||
description: |
|
||||
Read-only. verdande shows a Google calendar beside its own dated tasks and
|
||||
never writes one back — writing needs the `calendar.events` scope and an
|
||||
answer to what happens when both sides moved the same meeting, which is a
|
||||
synchronisation model rather than a scope.
|
||||
|
||||
The list of calendars is fetched from Google when it is empty, so an
|
||||
account that connected during a hiccup corrects itself on the next look
|
||||
rather than showing an empty panel for ever.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Calendar" }
|
||||
delete:
|
||||
tags: [integrations]
|
||||
summary: Disconnect the calendar and forget its tokens.
|
||||
description: >
|
||||
The cached events go too. Unlike the tasks a mailbox made, none of this is
|
||||
the person's own work — it is a copy of somebody else's calendar, and it
|
||||
means nothing without the account.
|
||||
responses:
|
||||
"204": { description: Disconnected. }
|
||||
|
||||
/api/v1/calendar/calendars:
|
||||
put:
|
||||
tags: [integrations]
|
||||
summary: Choose which calendars are drawn.
|
||||
description: >
|
||||
The whole set in one write rather than one flip at a time, for the reason
|
||||
the projects' order is written the same way: the question is "these ones",
|
||||
and a list built out of several requests can land half applied. A calendar
|
||||
turned off has its cached events removed; they come back if it is turned
|
||||
on again.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shown:
|
||||
type: array
|
||||
items: { type: string }
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/calendar/authorize:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Begin the OAuth flow for a calendar.
|
||||
description: |
|
||||
The same registration Gmail signs in through, asking for
|
||||
`calendar.readonly` and coming back to `/oauth/calendar/callback`. Google
|
||||
issues a refresh token per authorisation, so connecting a calendar does not
|
||||
disturb a working Gmail connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
|
||||
/api/v1/calendar/sync:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Refresh the chosen calendars now.
|
||||
description: >
|
||||
Refreshes the list of calendars and replaces the cached window of events
|
||||
for the ones that are shown. `partial` means the budget ran out before the
|
||||
last calendar; what was read is kept, and the rest arrive on the next run.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events: { type: integer }
|
||||
partial: { type: boolean }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar/events:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The events of the chosen calendars, between two days.
|
||||
description: |
|
||||
Both ends inclusive, and an event that merely overlaps the range comes
|
||||
back — a grid asks which cells an event covers, not where it begins.
|
||||
|
||||
`from` and `to` in the answer are the window verdande holds a copy of,
|
||||
which is not the window that was asked for. A grid paged past it gets no
|
||||
events, and an empty answer with no explanation is a calendar quietly
|
||||
claiming a day is clear.
|
||||
parameters:
|
||||
- { name: from, in: query, required: true, schema: { type: string, format: date } }
|
||||
- { name: to, in: query, required: true, schema: { type: string, format: date } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/CalendarEvent" }
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -2635,6 +3094,69 @@ paths:
|
||||
"204": { description: Deleted }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares:
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [notes]
|
||||
summary: Who a note is shared with, and who it can be shared with.
|
||||
description: >
|
||||
Owner only. Returns the people the note is already shared with, each with
|
||||
their role, and the candidates the owner may add — everyone on the instance
|
||||
who is not already on the note.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shares:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/NoteShare" }
|
||||
candidates:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Person" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Share a note with a person, or change the role they hold.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id]
|
||||
properties:
|
||||
user_id: { type: string }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
responses:
|
||||
"204": { description: Shared }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/notes/{noteID}/shares/{userID}:
|
||||
delete:
|
||||
tags: [notes]
|
||||
summary: Take a person's access to a note away again.
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: userID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Unshared }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/notes/linking/{kind}/{targetID}:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -3150,6 +3672,26 @@ paths:
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/oauth/calendar/callback:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Where Google sends the browser after consent to a calendar.
|
||||
description: |
|
||||
A URI of its own rather than a reuse of Gmail's. One callback deciding from
|
||||
stored state which of the two flows had come back would put the choice of
|
||||
which token store to write behind a lookup — and when that goes wrong it
|
||||
goes wrong by overwriting a working Gmail connection with calendar tokens.
|
||||
|
||||
Answers with a redirect to `/indstillinger/integrationer`, not with JSON.
|
||||
An `org_internal` error here means the OAuth app is registered as Internal
|
||||
and the account is outside the organisation.
|
||||
parameters:
|
||||
- { name: code, in: query, schema: { type: string } }
|
||||
- { name: state, in: query, schema: { type: string } }
|
||||
- { name: error, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
@@ -3332,6 +3874,40 @@ components:
|
||||
email: { type: string }
|
||||
trigger: { type: string, enum: ["", starred, label, both] }
|
||||
label: { type: string }
|
||||
Calendar:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
connected: { type: boolean }
|
||||
account:
|
||||
type: string
|
||||
description: >
|
||||
The address of the connected account, taken from the id of its
|
||||
primary calendar — which is where Google puts it, so no profile
|
||||
scope has to be asked for to learn it.
|
||||
read_only:
|
||||
type: boolean
|
||||
description: >
|
||||
Always true today. Stated rather than assumed: an interface that
|
||||
quietly looks editable is one somebody will try to edit.
|
||||
has_client:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether an OAuth client is registered on this instance at all, so
|
||||
the page can explain rather than offer a button that answers 409.
|
||||
redirect_uri:
|
||||
type: string
|
||||
description: >
|
||||
What has to be registered with Google, spelled out. Derived from
|
||||
VERDANDE_BASE_URL and matched exactly — the single most likely
|
||||
thing to be wrong, and the error Google gives names neither value.
|
||||
last_sync_at: { type: string, format: date-time }
|
||||
calendars:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Calendar" }
|
||||
ImportResult:
|
||||
description: Created
|
||||
content:
|
||||
@@ -3349,6 +3925,62 @@ components:
|
||||
items: { type: string }
|
||||
|
||||
schemas:
|
||||
Calendar:
|
||||
type: object
|
||||
description: >
|
||||
One calendar inside a connected account. Kept whether or not it is shown,
|
||||
so turning one off and on again gives back the same row.
|
||||
properties:
|
||||
id: { type: string }
|
||||
remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." }
|
||||
name: { type: string }
|
||||
colour:
|
||||
type: string
|
||||
description: >
|
||||
Google's own hex value, not one of verdande's ten named colours. Those
|
||||
are names so the interface can restyle them per theme and they are
|
||||
verdande's palette; a Google calendar is told apart by the colour the
|
||||
person already knows it by.
|
||||
time_zone: { type: string }
|
||||
primary: { type: boolean }
|
||||
writable:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the connected account could write to it. Nothing does — the
|
||||
scope is read-only — and it is stored so the day something can, the
|
||||
interface knows which calendars could even be offered.
|
||||
shown: { type: boolean }
|
||||
|
||||
CalendarEvent:
|
||||
type: object
|
||||
description: >
|
||||
One occurrence, in the shape a grid asks about. A recurrence is expanded by
|
||||
Google into its occurrences, so what arrives here is a day, not a rule.
|
||||
properties:
|
||||
id: { type: string }
|
||||
calendar_id: { type: string }
|
||||
summary: { type: string }
|
||||
starts_at:
|
||||
type: string
|
||||
description: >
|
||||
RFC 3339 as Google wrote it, offset and all. Empty for an all-day
|
||||
event, which has a day rather than a moment — inventing midnight for it
|
||||
is how an event moves a day when a container moves time zone.
|
||||
ends_at: { type: string }
|
||||
start_day: { type: string, format: date }
|
||||
end_day:
|
||||
type: string
|
||||
format: date
|
||||
description: >
|
||||
The last day the event covers, inclusive. Google's all-day end date is
|
||||
exclusive and is corrected on the way in, once, rather than by every
|
||||
reader.
|
||||
all_day: { type: boolean }
|
||||
location: { type: string }
|
||||
url: { type: string, description: "Opens the event in Google's own interface." }
|
||||
calendar_name: { type: string }
|
||||
colour: { type: string }
|
||||
|
||||
Note:
|
||||
type: object
|
||||
description: A page of Markdown. The body is the stored form and the exported form.
|
||||
@@ -3360,6 +3992,14 @@ components:
|
||||
pinned: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
shared_with_me:
|
||||
type: boolean
|
||||
description: >
|
||||
True when the reader reached this note through a direct share rather
|
||||
than by owning it or its project. What puts it in the Delt med mig group.
|
||||
owner:
|
||||
allOf: [{ $ref: "#/components/schemas/Person" }]
|
||||
description: Who the note belongs to, present on a note shared with the reader.
|
||||
links:
|
||||
type: array
|
||||
description: What the body points at, read out of the text on the way in.
|
||||
@@ -3376,6 +4016,21 @@ components:
|
||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||
pinned: { type: boolean }
|
||||
|
||||
Person:
|
||||
type: object
|
||||
description: A user, as much of one as a name and a colour.
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
avatar_color: { type: string }
|
||||
|
||||
NoteShare:
|
||||
type: object
|
||||
description: One person's direct grant on a note.
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/Person" }
|
||||
role: { type: string, enum: [viewer, editor] }
|
||||
|
||||
Mailbox:
|
||||
type: object
|
||||
description: One connected mailbox. The password and any token are never returned.
|
||||
|
||||
@@ -7,14 +7,14 @@ verdande ships with a Rune manifest for
|
||||
|
||||
```
|
||||
create container: Error response from daemon:
|
||||
No such image: ghcr.io/kristianwind/verdande:latest
|
||||
No such image: gitea.nolimit.dk/kw/verdande:latest
|
||||
```
|
||||
|
||||
This message is a liar. It does not mean the tag is missing from the registry —
|
||||
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||
without saying so.
|
||||
|
||||
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||
The usual cause is a **private** package. Yggdrasil pulls anonymously: its
|
||||
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||
looks locally — then reports the image as missing.
|
||||
@@ -24,10 +24,28 @@ looks locally — then reports the image as missing.
|
||||
sends them itself with each request. Yggdrasil is a different client and
|
||||
sends none, so logging in changes nothing for the panel.
|
||||
|
||||
**Make the package public.** Under
|
||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||
readable, which is what the manifest and these pages have always assumed.
|
||||
**The package has to be readable without credentials**, and the only way to know
|
||||
is to ask the registry for a token as a stranger:
|
||||
|
||||
```bash
|
||||
curl -s "https://ghcr.io/token?scope=repository:kristianwind/verdande:pull&service=ghcr.io"
|
||||
```
|
||||
|
||||
A 200 with a `token` in it means anyone can pull, which means the panel can. A
|
||||
401 means it cannot, whatever the package page says.
|
||||
|
||||
!!! danger "Do not test this with `docker --config /tmp/empty pull`"
|
||||
It looks like the honest test and is not. An empty config directory does not
|
||||
stop the machine's credential helper, so the pull is quietly authenticated
|
||||
with credentials you forgot you had — and reports success for an image no
|
||||
stranger can fetch. This cost a production instance an hour of downtime: the
|
||||
image was declared public on that evidence, the rune was pointed at it, and
|
||||
the host — which had never logged in — could not pull a thing.
|
||||
|
||||
A self-hosted Gitea is where this bites. An instance with `REQUIRE_SIGNIN_VIEW`
|
||||
turned on refuses anonymous tokens for its registry too, and no per-package
|
||||
setting overrides it. Making the source repository public does not change it
|
||||
either; that was tried.
|
||||
|
||||
Once it is public, press **Start** on the server you already have. There is no
|
||||
need to delete it and make another: the image reference is read from the rune at
|
||||
@@ -37,8 +55,8 @@ To keep the package private you have to put the image on the host yourself,
|
||||
before creating the server, and again after every release:
|
||||
|
||||
```bash
|
||||
sudo docker login ghcr.io -u kristianwind
|
||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||
sudo docker login gitea.nolimit.dk -u kw
|
||||
sudo docker pull gitea.nolimit.dk/kw/verdande:latest
|
||||
```
|
||||
|
||||
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 227 KiB |
@@ -1,14 +1,16 @@
|
||||
# Self-hosting
|
||||
# With Docker
|
||||
|
||||
verdande is one static binary and one directory. There is no database server, no
|
||||
cache, no queue and no second process.
|
||||
Running it yourself outside a panel: one static binary and one directory. There is
|
||||
no database server, no cache, no queue and no second process. (Yggdrasil is
|
||||
self-hosting too — for that route see [As a Rune](rune.md).)
|
||||
|
||||
## Docker
|
||||
|
||||
!!! note "If the pull is refused"
|
||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||
back to private, `docker login ghcr.io` with a token carrying the
|
||||
`read:packages` scope before pulling — and see
|
||||
The image is published from a self-hosted Gitea and is readable without
|
||||
credentials — the package is not linked to a repository, so its visibility
|
||||
follows the account rather than the (private) source repo. If it is ever set
|
||||
back to private, `docker login gitea.nolimit.dk` before pulling — and see
|
||||
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||
fails differently and much more confusingly under Yggdrasil.
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
@@ -14,9 +16,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -45,6 +47,12 @@ type Config struct {
|
||||
// ProviderCompatible and useful for a proxy in front of the others.
|
||||
BaseURL string
|
||||
Model string
|
||||
// AllowPrivate lets BaseURL be on this machine or its private network.
|
||||
//
|
||||
// Set by the caller from who is asking, never read from storage: it is the
|
||||
// answer to "may this person reach the LAN", and that answer can change after
|
||||
// the address was saved. See the handler for who gets it and why.
|
||||
AllowPrivate bool
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
@@ -71,12 +79,31 @@ type Client struct {
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
dial := safedial.Client
|
||||
if cfg.AllowPrivate {
|
||||
dial = safedial.AllowPrivate
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
// Generous: a local model on modest hardware can take a while to produce
|
||||
// its first token, and the alternative is a timeout that only ever fires
|
||||
// for the people running their own.
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
// safedial by default. `base_url` is typed into a field in the interface,
|
||||
// so it is a user-supplied address — and a server that fetches a
|
||||
// user-supplied address is a way to ask it to reach things the caller
|
||||
// cannot: the panel next door, a database on the same bridge, the cloud
|
||||
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||
//
|
||||
// The check is on the resolved address rather than on the URL, because a
|
||||
// name answers whatever its owner says — and can answer differently the
|
||||
// second time, after a parse-time check has passed.
|
||||
//
|
||||
// And then the exception, which is the whole reason this provider exists:
|
||||
// a model you run yourself IS on that network. Blocking it made the two
|
||||
// lines above true and the feature useless — the comment about a local
|
||||
// model being slow to its first token was written for a case the dialler
|
||||
// refused to reach. AllowPrivate is granted per request, not per address.
|
||||
http: dial(120 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +295,35 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
// The provider's own message is included: for a local model it is usually
|
||||
// the actual problem ("model not found"), and hiding it would leave the
|
||||
// operator with nothing to go on.
|
||||
return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
|
||||
// The provider's body is no longer passed back to the caller.
|
||||
//
|
||||
// It was, and combined with a free-text base URL that made a working
|
||||
// internal scanner: point it at a host, read the first three hundred bytes
|
||||
// of whatever answered. The operator still needs the message — for a local
|
||||
// model it is usually the actual problem — so it goes to the log, where it
|
||||
// is available to the person running the instance and to nobody else.
|
||||
return &upstreamError{Host: req.URL.Host, Status: resp.Status, Body: truncate(string(raw), 300)}
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
// upstreamError keeps the provider's own words for the log and away from the
|
||||
// response. Error() is what reaches a caller; Detail() is what is written down.
|
||||
type upstreamError struct {
|
||||
Host string
|
||||
Status string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *upstreamError) Error() string {
|
||||
return fmt.Sprintf("ai: %s said %s", e.Host, e.Status)
|
||||
}
|
||||
|
||||
// Detail is the whole of it, for the instance's own error log.
|
||||
func (e *upstreamError) Detail() string {
|
||||
return fmt.Sprintf("ai: %s said %s: %s", e.Host, e.Status, e.Body)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
|
||||
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TOTPStep returns the step a code matched, and it must be the same step across
|
||||
// the tolerance window — that number is what makes a code single-use, so a code
|
||||
// entered a little early or late has to spend the same step, not a new one.
|
||||
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
|
||||
secret, _, err := NewTOTPSecret("verdande", "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
|
||||
code := codeAt(t, secret, now)
|
||||
|
||||
want, err := TOTPStep(secret, code, now)
|
||||
if err != nil {
|
||||
t.Fatalf("the current code did not match: %v", err)
|
||||
}
|
||||
// Verified one step late — the code is still valid, and must report the step it
|
||||
// was issued at, not the step it was checked at.
|
||||
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("code rejected one step late: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
|
||||
}
|
||||
|
||||
if _, err := TOTPStep(secret, "000000", now); err == nil {
|
||||
t.Error("a wrong code returned a step")
|
||||
}
|
||||
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
|
||||
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
|
||||
}
|
||||
}
|
||||
|
||||
// codeAt produces the code an authenticator app would display at that moment,
|
||||
// using the same settings VerifyTOTP checks against.
|
||||
func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boombuler/barcode/qr"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
@@ -49,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
|
||||
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
|
||||
// base64 blob to the page — one dark path on a white square, which is what a scanner
|
||||
// needs whatever theme the page is in.
|
||||
//
|
||||
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
|
||||
// this borrows a dependency rather than adding one.
|
||||
func TOTPQRSVG(uri string) (string, error) {
|
||||
code, err := qr.Encode(uri, qr.M, qr.Auto)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: encode totp qr: %w", err)
|
||||
}
|
||||
b := code.Bounds()
|
||||
n := b.Dx()
|
||||
// A four-module quiet zone all round, which the spec requires and scanners rely
|
||||
// on — without it the code runs to the edge and a reader cannot find its border.
|
||||
const quiet = 4
|
||||
size := n + quiet*2
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
|
||||
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
|
||||
for y := 0; y < n; y++ {
|
||||
for x := 0; x < n; x++ {
|
||||
// Dark modules are black; the red channel is 0 for those and full for the
|
||||
// light ones. One 1×1 rect per dark module, merged into a single path.
|
||||
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
|
||||
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString(`"/></svg>`)
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||
// code again before enrolment is confirmed.
|
||||
func TOTPURI(secret, issuer, account string) string {
|
||||
@@ -82,6 +119,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TOTPStep verifies a code and returns the counter of the step it matched, so the
|
||||
// caller can refuse to accept the same step twice.
|
||||
//
|
||||
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
|
||||
// window is not enough to stop a replay: a code seen in flight stays valid for the
|
||||
// rest of its period, and the fix is to record which step was spent and never
|
||||
// accept that step or an earlier one again. That needs the number, so this walks
|
||||
// the same window ValidateCustom would — one step either side — and reports the
|
||||
// counter of the match. The comparison is constant time for the same reason the
|
||||
// password check is: a code is a secret being checked against a computed value.
|
||||
func TOTPStep(secret, code string, now time.Time) (int64, error) {
|
||||
if secret == "" {
|
||||
return 0, ErrNoTOTPSecret
|
||||
}
|
||||
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||
|
||||
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
|
||||
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
|
||||
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
|
||||
Period: totpPeriod,
|
||||
Skew: 0,
|
||||
Digits: totpDigits,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||
return at.Unix() / totpPeriod, nil
|
||||
}
|
||||
}
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
|
||||
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
||||
// and the hashes to store. Without these, losing a device means losing the account,
|
||||
// and the only fix left is editing the database by hand.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package beacon answers one question for the project, and refuses to answer any
|
||||
// other: how many installations are out there.
|
||||
//
|
||||
// It sends two values once a day — an anonymous id the installation made up for
|
||||
// itself, and the version it is running — and nothing else. No IP is stored by
|
||||
// the collector, no hostname, no domain, no account, no usage. The payload is
|
||||
// small enough to be printed in full on the settings page, and it is, because a
|
||||
// promise about telemetry is worth exactly as much as the reader's ability to
|
||||
// check it.
|
||||
//
|
||||
// # On by default, and off in one click
|
||||
//
|
||||
// The alternative that was asked for first was mandatory, and the argument
|
||||
// against it is practical rather than principled: it cannot be enforced. The
|
||||
// domain can be blocked, the binary can be patched, and a fork removes it in its
|
||||
// first commit — so mandatory buys nothing over on-by-default, while costing the
|
||||
// trust the rest of this program works for. Secrets are sealed here, the key is
|
||||
// kept out of the database, and the backup page warns you what a copy contains.
|
||||
// Telemetry you are not allowed to refuse is the one sentence in that story that
|
||||
// does not fit.
|
||||
//
|
||||
// So: on by default, said plainly, one switch to turn it off. That collects
|
||||
// essentially the same number and leaves nobody feeling tricked.
|
||||
//
|
||||
// # Where this disagrees with the rest of the program
|
||||
//
|
||||
// `config.UpdateCheck` is off unless asked for, and its comment gives the reason:
|
||||
// "a self-hosted app that reaches out without being told to has broken the deal
|
||||
// its operator made by self-hosting." That is the same situation as this one, and
|
||||
// the two defaults do not agree.
|
||||
//
|
||||
// The disagreement is written down here rather than smoothed over, because it is
|
||||
// a real one and the reader deserves to see both halves. The case for treating
|
||||
// them differently is that the update check reveals an instance to a third party
|
||||
// (GitHub) and returns something the operator can act on, while this returns
|
||||
// nothing and goes to the project itself — and that a count nobody sends is a
|
||||
// count that does not exist, whereas an update nobody checks for is one you can
|
||||
// still go and look up. Whether that is a distinction or an excuse is a judgement
|
||||
// call, and it was made deliberately by the person whose instance this is.
|
||||
//
|
||||
// If it is ever decided the other way, the change is one word: the default in
|
||||
// `beaconSettings`. Nothing else in this package assumes which way it points.
|
||||
//
|
||||
// # What the collector can still see
|
||||
//
|
||||
// A request has a source address whether or not anybody writes it down. The
|
||||
// collector does not store or log it, and the code is arranged so that it never
|
||||
// reaches a function that could — but it passes through a reverse proxy and a
|
||||
// tunnel on the way, and those keep their own logs. Saying "we send no IP" would
|
||||
// be a half-truth; what is true is that nothing here records one.
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultCollector is where a fresh installation reports, unless it is told
|
||||
// otherwise. Somebody running their own fleet points it at their own instance;
|
||||
// the field is on the settings page for exactly that.
|
||||
const DefaultCollector = "https://verdande.yggdrasilpanel.com/api/v1/beacon"
|
||||
|
||||
// Payload is the entire message. Two fields, and adding a third is a change to
|
||||
// what this program promises, not a change to a struct.
|
||||
type Payload struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InstanceID and Version are checked on the way in as well as written on the way
|
||||
// out, because the collector's endpoint is unauthenticated: anything that reaches
|
||||
// it is a stranger's bytes, and it should be able to store only the two shapes it
|
||||
// expects.
|
||||
var (
|
||||
idShape = regexp.MustCompile(`^[0-9a-fA-F-]{16,64}$`)
|
||||
versionShape = regexp.MustCompile(`^v?[0-9]{1,4}(\.[0-9]{1,5}){0,3}(-[0-9A-Za-z.]{1,20})?$`)
|
||||
)
|
||||
|
||||
// ValidID reports whether an id is the shape this program writes.
|
||||
func ValidID(s string) bool { return idShape.MatchString(s) }
|
||||
|
||||
// ValidVersion allows a version number and the word this binary uses when it was
|
||||
// built outside a release.
|
||||
func ValidVersion(s string) bool { return s == "dev" || versionShape.MatchString(s) }
|
||||
|
||||
// Send delivers one ping. It is deliberately dull: a short timeout, no retry, no
|
||||
// queue, and a failure that is returned rather than made anybody's problem.
|
||||
//
|
||||
// No retry, because the next pass is tomorrow and a missed day changes a count of
|
||||
// installations by nothing. A telemetry ping that retries is a telemetry ping
|
||||
// that can hammer somebody's server on the day it is down.
|
||||
func Send(ctx context.Context, client *http.Client, collector string, p Payload) error {
|
||||
if !ValidID(p.InstanceID) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed id")
|
||||
}
|
||||
// Versionen kontrolleres også her, ikke kun hos modtageren. Kommentaren ved
|
||||
// mønstrene lovede begge veje, og gjorde det ikke — og en installation, der
|
||||
// sender noget mærkeligt, skal opdage det hos sig selv frem for at være noget,
|
||||
// en fremmed collector skal filtrere fra.
|
||||
if !ValidVersion(p.Version) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed version")
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collector, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// A plain user agent with the version already in the payload. Nothing is
|
||||
// learned from it that the body does not already say, and it makes the request
|
||||
// legible in a log rather than anonymous-looking.
|
||||
req.Header.Set("User-Agent", "verdande-beacon/1")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Read and discard, so the connection can be reused rather than dropped.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("beacon: collector answered %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Hvad der bliver sendt, tegn for tegn.
|
||||
//
|
||||
// Prøven er skrevet imod *hele* kroppen og ikke imod enkelte felter, fordi det er
|
||||
// den, der er løftet: siden siger "præcis det her og ikke mere", og et felt lagt
|
||||
// til senere skal vælte noget. En prøve, der kun så efter, at instance_id var med,
|
||||
// ville lade en ny nøgle glide igennem uden at nogen opdagede det.
|
||||
func TestSendsExactlyTwoFields(t *testing.T) {
|
||||
var got map[string]any
|
||||
var contentType string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contentType = r.Header.Get("Content-Type")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Errorf("kroppen er ikke JSON: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a",
|
||||
Version: "v0.26.3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q", contentType)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("der blev sendt %d felter, ikke to: %v", len(got), got)
|
||||
}
|
||||
if got["instance_id"] != "01a01b16-4e54-7000-9eaa-2f8eb0629c3a" {
|
||||
t.Errorf("instance_id = %v", got["instance_id"])
|
||||
}
|
||||
if got["version"] != "v0.26.3" {
|
||||
t.Errorf("version = %v", got["version"])
|
||||
}
|
||||
}
|
||||
|
||||
// En collector, der er nede, er ikke denne installations problem.
|
||||
func TestSendReportsAFailureRatherThanRetrying(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a", Version: "v1.0.0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("en 500 skal give en fejl")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("der blev prøvet %d gange; en telemetri-ping må ikke hamre på en server, der er nede", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Et id, der ikke er id-formet, sendes slet ikke — så en fejl her ikke bliver til
|
||||
// noget, en fremmed collector skal filtrere fra.
|
||||
func TestSendRefusesAMalformedID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("der blev sendt noget, som ikke skulle sendes")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "; DROP TABLE beacon_installs", Version: "v1.0.0",
|
||||
}); err == nil {
|
||||
t.Fatal("et misformet id skal afvises før det forlader maskinen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapes(t *testing.T) {
|
||||
ok := []string{"01a01b16-4e54-7000-9eaa-2f8eb0629c3a", "89df44d7-3742-4611-9a43-db0b07733c6d"}
|
||||
for _, s := range ok {
|
||||
if !ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = false", s)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "kort", "../../etc/passwd", "01a01b16-4e54-7000-9eaa-2f8eb0629c3a'; --",
|
||||
"<script>alert(1)</script>"}
|
||||
for _, s := range bad {
|
||||
if ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = true", s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range []string{"v0.26.3", "0.26.3", "v1.2.3-rc.1", "dev"} {
|
||||
if !ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "v<script>", "v" + string(make([]byte, 200))} {
|
||||
if ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -59,10 +60,19 @@ type Config struct {
|
||||
// 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
|
||||
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
|
||||
// the same reason: a slow Google held the request open past what Cloudflare is
|
||||
// willing to wait, and the browser got Cloudflare's HTML instead of an error
|
||||
// this server could explain.
|
||||
CalendarSyncBudget time.Duration
|
||||
|
||||
// Where Google is. Empty in production; a test points them at a server it
|
||||
// controls, which is the only way to exercise a Google that is slow or refuses.
|
||||
// The token endpoint is shared — Gmail and Calendar sign in through the same
|
||||
// registration — and the two APIs are not.
|
||||
GoogleTokenURL string
|
||||
GmailAPIURL string
|
||||
CalendarAPIURL string
|
||||
|
||||
PanelURL string
|
||||
PanelToken string
|
||||
@@ -71,6 +81,26 @@ type Config struct {
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
// TrustedProxies are the peers whose forwarded-for header may be believed.
|
||||
//
|
||||
// The rate limiter and the audit log key on the caller's address, and that
|
||||
// address is only as honest as wherever it was read. Read it from a header that
|
||||
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
|
||||
// every request and to write someone else's IP into the log. So the header is
|
||||
// believed only when the machine that *connected* is one of these — a reverse
|
||||
// proxy or tunnel the operator put there — and ignored when the connection came
|
||||
// straight off the internet.
|
||||
//
|
||||
// The default is "the peer is on a private network", which is exactly the shape
|
||||
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
|
||||
// host or the docker bridge, so its address is loopback or RFC1918. A directly
|
||||
// exposed instance sees a public peer, trusts nothing, and keys on the real
|
||||
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
|
||||
// or "none" to trust no proxy at all.
|
||||
TrustedProxies []netip.Prefix
|
||||
// RealIPHeader is where the client address is read from, for a trusted peer.
|
||||
RealIPHeader string
|
||||
|
||||
Dev bool
|
||||
}
|
||||
|
||||
@@ -98,14 +128,15 @@ func Load() (*Config, error) {
|
||||
TrashRetention: 30 * 24 * time.Hour,
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
CalendarSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -139,9 +170,70 @@ func Load() (*Config, error) {
|
||||
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
|
||||
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
|
||||
// families' link-local. A proxy an operator stands up in front of this server is
|
||||
// on one of these; a stranger connecting directly is on none.
|
||||
var defaultTrustedProxies = mustPrefixes(
|
||||
"127.0.0.0/8", "::1/128",
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
"fc00::/7",
|
||||
"169.254.0.0/16", "fe80::/10",
|
||||
)
|
||||
|
||||
// parseTrustedProxies reads the override. Empty keeps the private-network default;
|
||||
// the literal "none" trusts no proxy at all, for an instance that is exposed
|
||||
// directly and must never believe a forwarded header.
|
||||
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultTrustedProxies, nil
|
||||
}
|
||||
if strings.EqualFold(raw, "none") {
|
||||
return nil, nil
|
||||
}
|
||||
var out []netip.Prefix
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
|
||||
// naming one proxy does not have to know CIDR notation to do it.
|
||||
if !strings.Contains(part, "/") {
|
||||
addr, err := netip.ParseAddr(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
|
||||
}
|
||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
continue
|
||||
}
|
||||
p, err := netip.ParsePrefix(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
|
||||
}
|
||||
out = append(out, p.Masked())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustPrefixes(cidrs ...string) []netip.Prefix {
|
||||
out := make([]netip.Prefix, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
out = append(out, netip.MustParsePrefix(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
|
||||
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
||||
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
||||
@@ -152,6 +244,15 @@ func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups"
|
||||
// configured separately and left to drift out of step with it.
|
||||
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
|
||||
|
||||
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
|
||||
//
|
||||
// One callback that decided from server-side state which of the two flows had come
|
||||
// back would save the operator one line in Google Cloud, and would put the choice
|
||||
// of *which token store to write* behind a lookup. When that goes wrong it goes
|
||||
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
|
||||
// nobody would think to look for. Two paths cannot do that.
|
||||
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" }
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
|
||||
@@ -60,6 +60,20 @@ func Parse(query string, ctx Context) (Compiled, error) {
|
||||
if len(p.tokens) == 0 {
|
||||
return Compiled{}, fmt.Errorf("filteret er tomt")
|
||||
}
|
||||
// Refused before it is parsed, not while.
|
||||
//
|
||||
// `combine` rebuilds the whole accumulated SQL string and re-copies the
|
||||
// argument slice per operator, so the cost is quadratic in the number of
|
||||
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
|
||||
// query string — took seven seconds of CPU and forty-six gigabytes of
|
||||
// allocation in one request. The preview endpoint runs this on whatever is
|
||||
// typed, and there is no write timeout on the server to cut it off.
|
||||
//
|
||||
// Two hundred terms is a filter nobody has ever written and far below where it
|
||||
// costs anything.
|
||||
if len(p.tokens) > maxTokens {
|
||||
return Compiled{}, fmt.Errorf("filteret er for langt")
|
||||
}
|
||||
|
||||
node, err := p.parseExpression()
|
||||
if err != nil {
|
||||
@@ -140,10 +154,19 @@ func isOperator(r rune) bool {
|
||||
|
||||
// --- parser -------------------------------------------------------------------
|
||||
|
||||
// The ceilings on one filter. See Parse for why.
|
||||
const (
|
||||
maxTokens = 200
|
||||
maxDepth = 32
|
||||
)
|
||||
|
||||
type parser struct {
|
||||
tokens []token
|
||||
pos int
|
||||
ctx Context
|
||||
// depth guards against a filter that is only parentheses. Each level is a
|
||||
// stack frame, and nothing else stops somebody sending a megabyte of them.
|
||||
depth int
|
||||
}
|
||||
|
||||
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
|
||||
@@ -187,6 +210,13 @@ func (p *parser) parseTerm() (Compiled, error) {
|
||||
if p.done() {
|
||||
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
|
||||
}
|
||||
// Nesting is stack depth. A filter made of half a million opening parentheses
|
||||
// is a megabyte of text and a stack frame for each.
|
||||
p.depth++
|
||||
defer func() { p.depth-- }()
|
||||
if p.depth > maxDepth {
|
||||
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
|
||||
}
|
||||
|
||||
switch t := p.peek(); t.kind {
|
||||
case tokNot:
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Package gcal reads a Google Calendar. It never writes one.
|
||||
//
|
||||
// The scope is calendar.readonly, and that is the whole of the decision: verdande
|
||||
// already is a CalDAV server, so a Google event shown here is a second calendar
|
||||
// laid over the first, not a copy Google and verdande now both have to agree
|
||||
// about. Writing needs `calendar.events`, and it needs an answer to what happens
|
||||
// when both sides moved the same meeting — which is a synchronisation model, not a
|
||||
// scope. Read first; write later and to one calendar at a time.
|
||||
//
|
||||
// What the API gives back and what this returns are deliberately different shapes.
|
||||
// Google answers with RFC 3339 instants for a timed event and bare dates for an
|
||||
// all-day one, and with an *exclusive* end date for the second. A calendar grid
|
||||
// asks one question — "which cells does this event cover" — so that is what an
|
||||
// Event answers, in days, worked out once here rather than in every caller.
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
// Scope is read-only, and only the calendar.
|
||||
//
|
||||
// Google also offers calendar.calendarlist.readonly and calendar.events.readonly,
|
||||
// which together are strictly less than this — they leave out access rules and
|
||||
// account settings, which verdande has no use for. They are the tighter pair and
|
||||
// worth moving to; they are also newer, and this one has been documented and
|
||||
// accepted unchanged for a decade. A consent screen that fails with `invalid_scope`
|
||||
// is not a place to be adventurous on somebody else's Google account.
|
||||
const Scope = "https://www.googleapis.com/auth/calendar.readonly"
|
||||
|
||||
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
// The OAuth flow, which is Google's rather than the calendar's — the same one
|
||||
// Gmail signs in through, asking for the scope above. Aliased so a caller reads
|
||||
// one package for one feature.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
var NewPKCE = google.NewPKCE
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
|
||||
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
|
||||
var ErrUnauthorized = google.ErrUnauthorized
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
// base is where the Calendar API is. A field rather than a constant so a test
|
||||
// can point it at a server it controls; empty is Google. There is no other way
|
||||
// to exercise a calendar that is slow, empty, or refuses.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Calendar is one entry in the person's calendar list.
|
||||
type Calendar struct {
|
||||
ID string
|
||||
// Name is the summary Google holds. A calendar somebody renamed for themselves
|
||||
// carries summaryOverride instead, and that is the name they will look for.
|
||||
Name string
|
||||
// Colour is Google's own hex value for the calendar, which is what makes two
|
||||
// calendars in one grid tellable apart. Their own colour rather than one picked
|
||||
// here: it is the colour they already know the calendar by.
|
||||
Colour string
|
||||
TimeZone string
|
||||
Primary bool
|
||||
// Owner means the connected account can write to it. Nothing writes today —
|
||||
// it is stored so the day something does, the interface knows which calendars
|
||||
// could even be offered.
|
||||
Owner bool
|
||||
}
|
||||
|
||||
// Calendars lists what the connected account can see.
|
||||
func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) {
|
||||
var out []Calendar
|
||||
page := ""
|
||||
// Bounded rather than "until Google stops": a page token that never empties is
|
||||
// a loop that never ends, and the difference between a bug at Google and a
|
||||
// hung sync should not be this server hanging.
|
||||
for range 10 {
|
||||
v := url.Values{}
|
||||
v.Set("minAccessRole", "reader")
|
||||
v.Set("maxResults", "250")
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
SummaryOverride string `json:"summaryOverride"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
Primary bool `json:"primary"`
|
||||
AccessRole string `json:"accessRole"`
|
||||
Deleted bool `json:"deleted"`
|
||||
} `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
if item.Deleted {
|
||||
continue
|
||||
}
|
||||
name := item.SummaryOverride
|
||||
if name == "" {
|
||||
name = item.Summary
|
||||
}
|
||||
out = append(out, Calendar{
|
||||
ID: item.ID, Name: name, Colour: item.BackgroundColor,
|
||||
TimeZone: item.TimeZone, Primary: item.Primary,
|
||||
Owner: item.AccessRole == "owner" || item.AccessRole == "writer",
|
||||
})
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Event is one occurrence, in the shape a calendar grid asks about.
|
||||
type Event struct {
|
||||
ID string
|
||||
Summary string
|
||||
// StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day
|
||||
// one — an all-day event has no instant, only a day, and inventing midnight for
|
||||
// it is how an event moves a day every time a server changes time zone.
|
||||
StartsAt string
|
||||
EndsAt string
|
||||
// StartDay and EndDay are the first and last day the event covers, inclusive at
|
||||
// both ends. Google's all-day end date is exclusive; that is corrected here,
|
||||
// once, rather than in every reader.
|
||||
StartDay string
|
||||
EndDay string
|
||||
AllDay bool
|
||||
Location string
|
||||
// URL opens the event in Google's own interface. Same reasoning as the Gmail
|
||||
// link on a task made from a mail: what verdande shows is a pointer to the
|
||||
// event, not a second copy of it that somebody could try to edit.
|
||||
URL string
|
||||
}
|
||||
|
||||
// Events lists what falls inside a window, one calendar at a time.
|
||||
//
|
||||
// `singleEvents` expands a recurrence into its occurrences, which is the only form
|
||||
// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday
|
||||
// it produced. It also means verdande does not have to grow a second recurrence
|
||||
// engine that agrees with Google's about Danish summer time.
|
||||
//
|
||||
// from and to are dates, inclusive. The window is the caller's business; this
|
||||
// turns it into the half-open interval Google wants.
|
||||
func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) {
|
||||
start, err := time.Parse(dateLayout, from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: from: %w", err)
|
||||
}
|
||||
end, err := time.Parse(dateLayout, to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: to: %w", err)
|
||||
}
|
||||
|
||||
var out []Event
|
||||
page := ""
|
||||
for range 20 {
|
||||
v := url.Values{}
|
||||
v.Set("singleEvents", "true")
|
||||
v.Set("orderBy", "startTime")
|
||||
v.Set("maxResults", "2500")
|
||||
// UTC, and a day of slack at each end. An event at 23:30 on the last day in
|
||||
// Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is
|
||||
// 22:30 UTC the day *before* — so a window cut exactly at the requested
|
||||
// dates loses the events at both edges. The extra day is thrown away again
|
||||
// by the day fields below, which are computed from the event's own offset.
|
||||
v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339))
|
||||
v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339))
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []rawEvent `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode()
|
||||
if err := c.get(ctx, url, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
// A cancelled occurrence of a recurring event still comes back, so that
|
||||
// a client keeping its own copy knows to drop it. verdande replaces the
|
||||
// window wholesale, so it only has to not add it.
|
||||
if item.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
event, ok := item.event()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if event.EndDay < from || event.StartDay > to {
|
||||
continue // the slack above, thrown away
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
type rawEvent struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Location string `json:"location"`
|
||||
HTMLLink string `json:"htmlLink"`
|
||||
Start when `json:"start"`
|
||||
End when `json:"end"`
|
||||
}
|
||||
|
||||
type when struct {
|
||||
Date string `json:"date"`
|
||||
DateTime string `json:"dateTime"`
|
||||
}
|
||||
|
||||
// event turns Google's two shapes into the one a grid can read.
|
||||
//
|
||||
// The second return is false for an event with neither a date nor a dateTime —
|
||||
// which Google does return, for an event somebody is still being asked about. It
|
||||
// belongs on no day, so there is no cell to draw it in.
|
||||
func (r rawEvent) event() (Event, bool) {
|
||||
e := Event{
|
||||
ID: r.ID, Summary: strings.TrimSpace(r.Summary),
|
||||
Location: r.Location, URL: r.HTMLLink,
|
||||
}
|
||||
if e.Summary == "" {
|
||||
// Google's own interface says this for an event with no title, and a blank
|
||||
// chip in a grid reads as a rendering fault rather than as an empty title.
|
||||
e.Summary = "(uden titel)"
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Start.Date != "":
|
||||
e.AllDay = true
|
||||
e.StartDay = r.Start.Date
|
||||
// Google's all-day end is the day *after* the last one, so a one-day event
|
||||
// ends tomorrow. Subtracting a day is what makes "covers this cell" a
|
||||
// comparison rather than a special case at every call site.
|
||||
e.EndDay = r.Start.Date
|
||||
if day, err := time.Parse(dateLayout, r.End.Date); err == nil {
|
||||
if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) {
|
||||
e.EndDay = last.Format(dateLayout)
|
||||
}
|
||||
}
|
||||
case r.Start.DateTime != "":
|
||||
e.StartsAt = r.Start.DateTime
|
||||
e.EndsAt = r.End.DateTime
|
||||
// The day is the date part of the timestamp Google returned, which carries
|
||||
// the event's own offset. Parsing it into a time.Time and formatting it
|
||||
// back would ask *this process* what day it is — and a container running in
|
||||
// UTC would then move a 23:30 meeting in Copenhagen to the day before.
|
||||
e.StartDay = dayOf(r.Start.DateTime)
|
||||
e.EndDay = dayOf(r.End.DateTime)
|
||||
if e.EndDay == "" {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
// An event ending at midnight ends the evening before, as far as a grid is
|
||||
// concerned. Without this, every meeting that runs to 24:00 puts a chip in
|
||||
// the following day as well.
|
||||
if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") {
|
||||
if day, err := time.Parse(dateLayout, e.EndDay); err == nil {
|
||||
e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return Event{}, false
|
||||
}
|
||||
if e.EndDay < e.StartDay {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
// dayOf takes the date out of an RFC 3339 timestamp without interpreting it.
|
||||
func dayOf(stamp string) string {
|
||||
if len(stamp) < len(dateLayout) {
|
||||
return ""
|
||||
}
|
||||
day := stamp[:len(dateLayout)]
|
||||
if _, err := time.Parse(dateLayout, day); err != nil {
|
||||
return ""
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
func mustDay(s string) time.Time {
|
||||
day, _ := time.Parse(dateLayout, s)
|
||||
return day
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGoogle answers the two calls this package makes, out of whatever the test
|
||||
// hands it. There is no other way to exercise this: the real Calendar needs an
|
||||
// account, a registered client and somebody's consent, and none of the three can be
|
||||
// had inside a test — so the shapes Google returns are written down here and the
|
||||
// parsing is checked against them.
|
||||
func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(calendarList)
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
body, ok := events[id]
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
default:
|
||||
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return NewClient("token").At(srv.URL)
|
||||
}
|
||||
|
||||
func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) {
|
||||
client := fakeGoogle(t, map[string]any{
|
||||
"items": []map[string]any{
|
||||
{"id": "primary", "summary": "[email protected]", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"},
|
||||
{"id": "fam", "summary": "Family", "summaryOverride": "Familien",
|
||||
"backgroundColor": "#0b8043", "accessRole": "reader"},
|
||||
// A calendar removed from the list still arrives, flagged.
|
||||
{"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
got, err := client.Calendars(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got))
|
||||
}
|
||||
if got[1].Name != "Familien" {
|
||||
t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name)
|
||||
}
|
||||
if !got[0].Primary || !got[0].Owner {
|
||||
t.Errorf("the primary calendar came back as %+v", got[0])
|
||||
}
|
||||
if got[1].Owner {
|
||||
t.Error("a reader was reported as an owner; nothing may offer to write to it")
|
||||
}
|
||||
if got[0].Colour != "#3f51b5" {
|
||||
t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour)
|
||||
}
|
||||
}
|
||||
|
||||
// The three shapes an event arrives in, and the one shape a grid can read.
|
||||
func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"primary": map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"},
|
||||
},
|
||||
{
|
||||
// All-day, one day. Google's end is the day after.
|
||||
"id": "allday", "summary": "Fridag",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"},
|
||||
},
|
||||
{
|
||||
// All-day, three days.
|
||||
"id": "trip", "summary": "Aarhus",
|
||||
"start": map[string]string{"date": "2026-08-24"},
|
||||
"end": map[string]string{"date": "2026-08-27"},
|
||||
},
|
||||
{
|
||||
// Runs to midnight, which is the evening before as far as a
|
||||
// grid is concerned.
|
||||
"id": "late", "summary": "Nattevagt",
|
||||
"start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"},
|
||||
},
|
||||
{
|
||||
// A cancelled occurrence of a recurring event.
|
||||
"id": "off", "status": "cancelled", "summary": "Aflyst",
|
||||
"start": map[string]string{"date": "2026-08-25"},
|
||||
"end": map[string]string{"date": "2026-08-26"},
|
||||
},
|
||||
{
|
||||
// Neither a date nor a dateTime: belongs on no day at all.
|
||||
"id": "nowhere", "summary": "Uden tid",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byID := map[string]Event{}
|
||||
for _, e := range got {
|
||||
byID[e.ID] = e
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d events, want 4: %+v", len(got), got)
|
||||
}
|
||||
if _, ok := byID["off"]; ok {
|
||||
t.Error("a cancelled occurrence was kept; it is an event that is not happening")
|
||||
}
|
||||
if _, ok := byID["nowhere"]; ok {
|
||||
t.Error("an event with no time was kept; there is no cell to draw it in")
|
||||
}
|
||||
|
||||
timed := byID["timed"]
|
||||
if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" {
|
||||
t.Errorf("timed = %+v", timed)
|
||||
}
|
||||
if timed.StartsAt != "2026-08-20T14:00:00+02:00" {
|
||||
t.Errorf("the instant was rewritten: %q", timed.StartsAt)
|
||||
}
|
||||
|
||||
allday := byID["allday"]
|
||||
if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" {
|
||||
t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here",
|
||||
allday)
|
||||
}
|
||||
if allday.StartsAt != "" {
|
||||
t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment",
|
||||
allday.StartsAt)
|
||||
}
|
||||
|
||||
if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" {
|
||||
t.Errorf("trip = %+v, want 24th to 26th inclusive", trip)
|
||||
}
|
||||
if late := byID["late"]; late.EndDay != "2026-08-22" {
|
||||
t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+
|
||||
"or every one of them puts a chip in the following day", late.EndDay)
|
||||
}
|
||||
}
|
||||
|
||||
// An event with no title has to say so. A blank chip in a grid reads as a
|
||||
// rendering fault rather than as an empty title.
|
||||
func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{{
|
||||
"id": "x",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"},
|
||||
}}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Summary == "" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The window is asked for with a day of slack at each end, because an event late
|
||||
// in the evening in Copenhagen is the next day in UTC. The slack must not leak
|
||||
// out: an event on the day before the window is not in the window.
|
||||
func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{
|
||||
{"id": "before", "summary": "For tidligt",
|
||||
"start": map[string]string{"date": "2026-08-09"},
|
||||
"end": map[string]string{"date": "2026-08-10"}},
|
||||
{"id": "inside", "summary": "Med",
|
||||
"start": map[string]string{"date": "2026-08-10"},
|
||||
"end": map[string]string{"date": "2026-08-11"}},
|
||||
{"id": "after", "summary": "For sent",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "inside" {
|
||||
t.Fatalf("got %+v, want only the one inside the window", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A page token has to be followed, or a busy calendar silently shows its first
|
||||
// page and nothing else — which is a calendar that lies about a day being clear.
|
||||
func TestEveryPageIsRead(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
body := map[string]any{
|
||||
"items": []map[string]any{{"id": "a", "summary": "Første",
|
||||
"start": map[string]string{"date": "2026-08-11"},
|
||||
"end": map[string]string{"date": "2026-08-12"}}},
|
||||
"nextPageToken": "more",
|
||||
}
|
||||
if r.URL.Query().Get("pageToken") == "more" {
|
||||
body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden",
|
||||
"start": map[string]string{"date": "2026-08-12"},
|
||||
"end": map[string]string{"date": "2026-08-13"}}}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want both pages: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// A 401 is not a failure to retry; it is a connection that has ended. Telling the
|
||||
// two apart is what stops a revoked account being polled every ten minutes for ever.
|
||||
func TestARefusedTokenIsItsOwnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := NewClient("spent").At(srv.URL).Calendars(t.Context())
|
||||
if err != ErrUnauthorized {
|
||||
t.Fatalf("err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only, and only the calendar. The consent screen is what somebody agrees to,
|
||||
// and a scope that quietly grew is a promise quietly broken.
|
||||
func TestTheScopeIsReadOnly(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "calendar.readonly") {
|
||||
t.Errorf("scope = %q; nothing here writes a calendar", Scope)
|
||||
}
|
||||
}
|
||||
@@ -1,208 +1,67 @@
|
||||
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google, and the small slice of the Gmail API verdande needs.
|
||||
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||
// it asks for.
|
||||
//
|
||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
||||
// message does not delete the task, completing the task does not unstar the
|
||||
// message. Two-way would mean deciding what to do when both sides changed, and the
|
||||
// only honest answer to that is a synchronisation model far bigger than the feature.
|
||||
// The one thing verdande writes back is the star. A starred message becomes a
|
||||
// task, and once it has, the star comes off — exactly as the IMAP side takes the
|
||||
// flag off a mail it has imported, and for the same reason: the star meant "turn
|
||||
// this into a task", the task now exists, so the star is no longer true. Nothing
|
||||
// else is touched. Completing the task does not reach back to the mail; the mail
|
||||
// is a pointer the task was made from, not a copy the two have to be kept in step.
|
||||
//
|
||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
||||
// asking for less is what makes the consent screen something a person can agree to.
|
||||
// That one write is why the scope is gmail.modify rather than gmail.readonly.
|
||||
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
|
||||
// can do it — so the consent screen asks for more than it used to, and the trade
|
||||
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
|
||||
// than one that fills with stars nothing ever clears. Still not send, still not
|
||||
// delete — modify is labels, and the only label verdande removes is the one that
|
||||
// triggered the task.
|
||||
//
|
||||
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
||||
// The names below are aliases so that every call site — and this package's own
|
||||
// tests — still read as Gmail's, which is what they are: one registration, one
|
||||
// flow, one scope each.
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
// Read mail and change its labels, and only mail. Not profile, not contacts, not
|
||||
// send, not delete. The single write is removing the STARRED label from a message
|
||||
// that has become a task; see the package comment for why that costs modify.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.modify"
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
// The OAuth flow, which is Google's rather than Gmail's.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
// Endpoints is where Google is.
|
||||
//
|
||||
// A field rather than a constant so a test can point it at a server it controls.
|
||||
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
||||
// and "the sync comes back before whatever sits in front of this server gives up
|
||||
// on it" is exactly the kind of promise worth a test and impossible to make
|
||||
// against the real thing.
|
||||
//
|
||||
// The zero value is Google, so nothing outside a test has to know it exists.
|
||||
type Endpoints struct {
|
||||
Token string
|
||||
API string
|
||||
}
|
||||
|
||||
func (e Endpoints) token() string {
|
||||
if e.Token != "" {
|
||||
return e.Token
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
func (e Endpoints) api() string {
|
||||
if e.API != "" {
|
||||
return e.API
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
||||
// the instance rather than to a person: one registration, any number of users.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
// Endpoints is Google unless a test says otherwise.
|
||||
Endpoints Endpoints
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("gmail: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
var (
|
||||
NewPKCE = google.NewPKCE
|
||||
ErrUnauthorized = google.ErrUnauthorized
|
||||
)
|
||||
|
||||
// --- the API ---------------------------------------------------------------------
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
endpoints Endpoints
|
||||
http *http.Client
|
||||
// base is where the Gmail API is. A field rather than a constant so a test can
|
||||
// point it at a server it controls — and "the sync comes back before whatever
|
||||
// sits in front of this server gives up on it" is exactly the kind of promise
|
||||
// worth a test and impossible to make against the real thing. Empty is Google.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
@@ -210,11 +69,18 @@ func NewClient(accessToken string) *Client {
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(e Endpoints) *Client {
|
||||
c.endpoints = e
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Message is the part of a Gmail message a task is made from.
|
||||
type Message struct {
|
||||
ID string
|
||||
@@ -233,7 +99,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
||||
var parsed struct {
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.EmailAddress, nil
|
||||
@@ -257,7 +123,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -288,7 +154,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
@@ -308,30 +174,27 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gmail: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused")
|
||||
// Unstar takes the star off the given messages — one call for all of them, the way
|
||||
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
|
||||
// not ten.
|
||||
//
|
||||
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
|
||||
// label would need one, and verdande removes none. batchModify answers 204 with no
|
||||
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
|
||||
// an error on Google's side and nothing worth making a round trip to hear.
|
||||
func (c *Client) Unstar(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
body := struct {
|
||||
IDs []string `json:"ids"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
|
||||
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
|
||||
}
|
||||
|
||||
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
|
||||
// falling back to the address when there is no display name.
|
||||
@@ -375,11 +238,3 @@ func Query(trigger, label string) string {
|
||||
terms = append(terms, "newer_than:30d")
|
||||
return strings.Join(terms, " ")
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
|
||||
@@ -1,110 +1,81 @@
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||
// one the person reads on the consent screen. The flow itself is exercised in
|
||||
// internal/google; what belongs here is that Gmail asks for modify — read plus the
|
||||
// one write it makes, taking the star off an imported message — and nothing wider.
|
||||
func TestTheScopeIsModifyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.modify") {
|
||||
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
// Modify is the ceiling. Send, or the full account, would be a different promise
|
||||
// to the person on the consent screen than the one this feature needs.
|
||||
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
|
||||
if strings.Contains(Scope, wider) {
|
||||
t.Errorf("scope = %q; must not include %q", Scope, wider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
// Unstar removes the star from a batch in one call, and it addresses the messages
|
||||
// it was given and only the STARRED label.
|
||||
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody struct {
|
||||
IDs []string `json:"ids"`
|
||||
AddLabelIDs []string `json:"addLabelIds"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("Unstar: %v", err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
|
||||
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
if strings.Join(gotBody.IDs, ",") != "a,b" {
|
||||
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
|
||||
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
// Read-only, and only mail.
|
||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
||||
if len(gotBody.AddLabelIDs) != 0 {
|
||||
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
|
||||
// there is nothing to say to it anyway.
|
||||
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
|
||||
t.Fatalf("Unstar(nil): %v", err)
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
if called {
|
||||
t.Error("Unstar(nil) made a request; it should make none")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package google implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google.
|
||||
//
|
||||
// It was written inside internal/gmail, because Gmail was the only thing that
|
||||
// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the
|
||||
// same refresh — and the only thing that differs between them is one string: the
|
||||
// scope. A second copy would be a second place for "access_type=offline" to be
|
||||
// forgotten, which is the mistake that makes a connection die silently an hour
|
||||
// after it was made.
|
||||
//
|
||||
// What is *not* here is the API calls. Those differ completely, so gmail and gcal
|
||||
// keep their own clients and share only the way in.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
)
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud, plus the
|
||||
// scope one feature is asking for.
|
||||
//
|
||||
// The client belongs to the instance rather than to a person: one registration,
|
||||
// any number of users. The scope belongs to the feature: Gmail and Calendar sign
|
||||
// in through the same registration and ask for different things, and Google issues
|
||||
// a refresh token per authorisation — so connecting one does not disturb the other.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Scope string
|
||||
|
||||
// TokenURL is where Google is. A field rather than a constant so a test can
|
||||
// point it at a server it controls: there is no other way to exercise what
|
||||
// happens when the token endpoint is slow or refuses. Empty is Google, so
|
||||
// nothing outside a test has to know it exists.
|
||||
TokenURL string
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
func (c Config) tokenURL() string {
|
||||
if c.TokenURL != "" {
|
||||
return c.TokenURL
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", c.Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("google: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("google: authorisation was refused")
|
||||
|
||||
// Get performs an authorised GET and decodes the JSON body into out.
|
||||
//
|
||||
// Shared by the two API clients because the interesting part is the same in both:
|
||||
// a 401 is not a failure to retry, it is a connection that has ended, and telling
|
||||
// those apart is what stops a revoked account being polled every ten minutes for
|
||||
// ever.
|
||||
func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("google: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
|
||||
// body coming back. A write call — modifying labels on a message — answers with
|
||||
// 204 No Content when it worked, so anything 2xx is success and only the body is
|
||||
// decoded, and only when the caller asked for one.
|
||||
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("google: %s", resp.Status)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
Scope: "https://www.googleapis.com/auth/gmail.readonly",
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": cfg.Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
}
|
||||
|
||||
// The scope is the one thing that differs between the two features signing in
|
||||
// through this, so it has to come out of the config rather than out of a constant
|
||||
// somebody could forget to change.
|
||||
func TestTheScopeComesFromTheConfig(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calendar := "https://www.googleapis.com/auth/calendar.readonly"
|
||||
raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p)
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parsed.Query().Get("scope"); got != calendar {
|
||||
t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+
|
||||
"that grants the wrong thing", got, calendar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
// The bucket is cleared only when the login is *finished*.
|
||||
//
|
||||
// It used to be cleared here, on the strength of the password alone — before
|
||||
// the second factor had been asked for at all. That handed somebody who
|
||||
// already had the password an unlimited supply of ten-guess batches: log in,
|
||||
// get a fresh bucket and a fresh pending session, spend ten guesses on the
|
||||
// code, log in again. At three valid codes in any moment and twenty requests a
|
||||
// second, that is even odds inside an afternoon — against the one control that
|
||||
// exists precisely for when the password is already gone.
|
||||
//
|
||||
// So an account with a second factor clears nothing yet. handleLoginTOTP does
|
||||
// it, once the code is right.
|
||||
if !user.TOTPEnabled {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
}
|
||||
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
@@ -191,13 +205,52 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
session := sessionFrom(r.Context())
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
// Counted per account as well as per address.
|
||||
//
|
||||
// The address bucket alone is defeated by anybody with more than one, which is
|
||||
// the ordinary case — a phone and a laptop is already two. A code is six
|
||||
// digits: the only thing that makes it safe is that guesses are scarce, and
|
||||
// scarcity has to be counted against the thing being attacked.
|
||||
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
|
||||
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
||||
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"too many verification codes; wait a moment and try again")
|
||||
return
|
||||
}
|
||||
|
||||
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
|
||||
if err == nil {
|
||||
// The code is right; now it must also be unused. A TOTP code stays valid for
|
||||
// its whole window, so one observed in flight — a phishing page, a proxy,
|
||||
// over the shoulder — could otherwise be replayed until it expires. Spending
|
||||
// the step here makes it single-use: a second presentation of the same code,
|
||||
// or an earlier one, finds the step already gone.
|
||||
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
|
||||
if cerr != nil {
|
||||
s.internal(w, r, "consume totp step", cerr)
|
||||
return
|
||||
}
|
||||
if !fresh {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code has already been used")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
// different form while locked out of their account is a poor time to
|
||||
// introduce a second concept.
|
||||
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
||||
if rerr != nil || !used {
|
||||
// The pending session is left alive on purpose.
|
||||
//
|
||||
// Ending it on the first wrong digit was considered and rejected: it
|
||||
// would make somebody who mistyped one number type their password again,
|
||||
// and it buys almost nothing. What makes a six-digit code safe is that
|
||||
// guesses are scarce, and the per-account bucket above is what makes
|
||||
// them scarce — ten an hour, however many times the password is
|
||||
// re-entered. The attack this closes was never "one session, many
|
||||
// guesses"; it was "a fresh bucket for every password check".
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code is not valid")
|
||||
return
|
||||
@@ -205,6 +258,11 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("recovery code used", "user", user.ID)
|
||||
}
|
||||
|
||||
// Finished. Now the buckets may be cleared — both of them, so somebody who
|
||||
// fumbled the code twice is not still waiting once they get it right.
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
|
||||
@@ -27,6 +27,9 @@ type testServer struct {
|
||||
*httptest.Server
|
||||
db *store.DB
|
||||
client *http.Client
|
||||
// api er den samme server, routeren hænger på. Et par prøver har brug for at
|
||||
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
|
||||
api *Server
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
@@ -62,7 +65,8 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
// Discard: a passing test should print nothing, and these handlers log freely.
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
srv := httptest.NewServer(New(cfg, db, log, nil))
|
||||
api := New(cfg, db, log, nil)
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
@@ -75,6 +79,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
// The cookie jar is what makes this a session test rather than a
|
||||
// sequence of unrelated requests.
|
||||
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +306,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
|
||||
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
||||
// not lock the account.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
|
||||
// at slå den fra, og den har altid spurgt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
@@ -363,12 +372,58 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A TOTP code works once. A code seen in flight and replayed inside its window
|
||||
// must be refused the second time, or the second factor is only as good as the
|
||||
// thirty seconds an attacker has to reuse what they captured.
|
||||
func TestTOTPCodeIsSingleUse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
secret, _ := body["secret"].(string)
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// One code, captured once and presented twice.
|
||||
code := codeFor(t, secret)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// A second login, same code, same window.
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
||||
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
@@ -897,3 +952,65 @@ func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Andenfaktoren kan ikke slås til uden adgangskoden.
|
||||
//
|
||||
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
|
||||
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
|
||||
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
|
||||
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
|
||||
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
|
||||
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "det forkerte kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("en tom adgangskode blev accepteret")
|
||||
}
|
||||
}
|
||||
|
||||
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
|
||||
//
|
||||
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
|
||||
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
|
||||
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
|
||||
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
|
||||
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
|
||||
// her kunne blive ved i det uendelige.
|
||||
refused := 0
|
||||
for i := 0; i < 14; i++ {
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
continue
|
||||
}
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused == 0 {
|
||||
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/beacon"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The beacon, from the instance's side.
|
||||
//
|
||||
// Two halves that happen to live in the same binary: the part that reports in,
|
||||
// which every installation runs, and the part that receives, which only the one
|
||||
// acting as collector turns on. See internal/beacon for what is sent and why it
|
||||
// is on by default rather than compulsory.
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
type beaconConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
// anonymous id on first use.
|
||||
//
|
||||
// The id is generated here rather than derived from anything about the machine.
|
||||
// A hash of the hostname or the base URL would be stable and convenient and would
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
if v, ok := stored["enabled"].(bool); ok {
|
||||
cfg.Enabled = v
|
||||
}
|
||||
if v, ok := stored["collector_url"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
cfg.Collector = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := stored["instance_id"].(string); ok && beacon.ValidID(v) {
|
||||
cfg.InstanceID = v
|
||||
}
|
||||
if v, ok := stored["is_collector"].(bool); ok {
|
||||
cfg.IsCollector = v
|
||||
}
|
||||
if v, ok := stored["publish_count"].(bool); ok {
|
||||
cfg.PublishCount = v
|
||||
}
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
if err := s.saveBeaconSettings(ctx, cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error {
|
||||
return s.db.SetInstanceSettings(ctx, beaconScope, map[string]any{
|
||||
"enabled": cfg.Enabled,
|
||||
"collector_url": cfg.Collector,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon summary", err)
|
||||
return
|
||||
}
|
||||
out.Stats = &stats
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type beaconRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
if req.IsCollector != nil {
|
||||
cfg.IsCollector = *req.IsCollector
|
||||
}
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
url = beacon.DefaultCollector
|
||||
}
|
||||
// http and https only, and said as a field error rather than a 500: an
|
||||
// operator pointing this at their own collector will mistype it, and the
|
||||
// page should say which field is wrong.
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"collector_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
cfg.Collector = url
|
||||
}
|
||||
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.handleBeaconStatus(w, r)
|
||||
}
|
||||
|
||||
// --- the collector's side ---------------------------------------------------------
|
||||
|
||||
// handleBeaconPing receives one report. Unauthenticated, because the whole point
|
||||
// is that a stranger's installation can reach it.
|
||||
//
|
||||
// Nothing about the request is recorded except the two values in the body. The
|
||||
// source address is not read here, and there is no code path from this function
|
||||
// to anything that could write one down — which is the only version of that
|
||||
// promise worth making.
|
||||
func (s *Server) handleBeaconPing(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
// An instance that is not a collector does not quietly accept and drop pings —
|
||||
// it says there is nothing here. Otherwise every installation in the world is a
|
||||
// spam sink that answers 200 to anything.
|
||||
if !cfg.IsCollector {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "this instance is not a beacon collector")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
|
||||
var p beacon.Payload
|
||||
if err := decodeJSON(w, r, &p); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Checked, not trusted. This is the one endpoint anybody can post to, and the
|
||||
// two columns behind it should only ever hold the two shapes this program
|
||||
// writes — an id that is not id-shaped is somebody probing, not an install.
|
||||
if !beacon.ValidID(p.InstanceID) {
|
||||
writeFieldErrors(w, map[string]string{"instance_id": "malformed"})
|
||||
return
|
||||
}
|
||||
if p.Version != "" && !beacon.ValidVersion(p.Version) {
|
||||
writeFieldErrors(w, map[string]string{"version": "malformed"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.RecordBeacon(r.Context(), p.InstanceID, p.Version); err != nil {
|
||||
s.internal(w, r, "record beacon", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleBeaconCount publishes the number, when the collector has been told to.
|
||||
//
|
||||
// Thirty days rather than a running total: a total only climbs, so it stops
|
||||
// meaning "how many people use this" and starts meaning "how many ever tried it".
|
||||
func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.IsCollector || !cfg.PublishCount {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not published")
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": n, "window_days": 30})
|
||||
}
|
||||
|
||||
// SendBeacon reports this installation, at most once a day.
|
||||
//
|
||||
// Exported and handed to the jobs runner the same way the mailbox syncs are: the
|
||||
// settings, the anonymous id and the version all live on this side, and the
|
||||
// runner should not have to know about any of them.
|
||||
//
|
||||
// The day is counted from the last successful send, not from a wall-clock hour.
|
||||
// A fleet of installations that all pinged at 03:00 would arrive in one spike;
|
||||
// counted this way they spread themselves out by when each was started.
|
||||
func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
cfg, err := s.beaconSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
// The collector does not report to itself over the network. It would work, and
|
||||
// it would also mean the one instance whose count matters most depends on its
|
||||
// own tunnel being up to be counted.
|
||||
if cfg.IsCollector {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
|
||||
// safedial, ikke http.DefaultClient. Adressen kan skrives i fladen, og en
|
||||
// administrator, der peger den på 127.0.0.1 eller på 169.254.169.254, ville
|
||||
// ellers have en maskine, der henter indefra på kommando — og svaret på om den
|
||||
// svarede, er i sig selv det, sådan et forsøg leder efter.
|
||||
if err := beacon.Send(ctx, safedial.Client(15*time.Second), cfg.Collector, beacon.Payload{
|
||||
InstanceID: cfg.InstanceID,
|
||||
Version: Version,
|
||||
}); err != nil {
|
||||
// Logged at info, not error. The collector being unreachable is not this
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if getErr == nil {
|
||||
// The task decides, not the URL.
|
||||
//
|
||||
// The check above is about the project the *client named*, and a client
|
||||
// names both halves of this address. Asking the project for permission and
|
||||
// then taking the task id from the same string ties nothing together: a
|
||||
// viewer on a shared project could name a project of their own — the Inbox,
|
||||
// which everybody owns and may edit — and put the shared task's id in the
|
||||
// filename. The project check passed on the Inbox and the write landed on a
|
||||
// task they may only read.
|
||||
//
|
||||
// GetTask does not close it either: it answers "may this person see it",
|
||||
// and a viewer may. `CanEdit` is the question, and it is the same one
|
||||
// handleUpdateTask asks — the API door has always asked it.
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// And it has to live where the address says it does. Without this an editor
|
||||
// on two projects can reach either through the other's URL — nothing is
|
||||
// escalated by that on its own, but a URL allowed to lie about where a
|
||||
// resource lives is how the case above got in.
|
||||
if existing.ProjectID != projectID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// An update. Only the fields a task client can express are touched;
|
||||
// everything else the task carries stays as it is.
|
||||
content := parsed.Summary
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/gcal"
|
||||
"github.com/kristianwind/verdande/internal/ics"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// defaultCalendarSyncBudget bounds one "fetch now" when nothing else says
|
||||
// otherwise. Well under the hundred seconds a proxy is willing to wait, so this
|
||||
// server is the one that answers a slow Google rather than Cloudflare's own HTML
|
||||
// page — which is how a failure arrives with no code, no message and nothing in
|
||||
// the error log.
|
||||
const defaultCalendarSyncBudget = 25 * time.Second
|
||||
|
||||
// The window verdande keeps a copy of.
|
||||
//
|
||||
// A month grid pages, so asking Google on every arrow press would mean a round trip
|
||||
// for a question answered ninety seconds ago — a calendar slower than the calendar
|
||||
// it is showing. A cached window is the alternative, and a window has to end
|
||||
// somewhere. Ninety days back is roughly "when was that", a year forward is past
|
||||
// anything anybody has booked; between them they cover every screen the grid can
|
||||
// reach without a person noticing an edge.
|
||||
//
|
||||
// The edge is still real, and the events endpoint says where it is rather than
|
||||
// answering an empty list for a month outside it. A calendar that quietly shows no
|
||||
// events is a calendar that lies about a day being clear.
|
||||
const (
|
||||
calendarWindowBack = 90
|
||||
calendarWindowAhead = 365
|
||||
calendarDateLayout = "2006-01-02"
|
||||
calendarProviderName = "google"
|
||||
)
|
||||
|
||||
func (s *Server) calendarClient(accessToken string) *gcal.Client {
|
||||
return gcal.NewClient(accessToken).At(s.cfg.CalendarAPIURL)
|
||||
}
|
||||
|
||||
// calendarConfig is the same registration Gmail signs in through, asking for a
|
||||
// different scope and coming back to a different URI.
|
||||
//
|
||||
// One OAuth client, two features. Google issues a refresh token per authorisation
|
||||
// and keeps up to a hundred of them live per account, each carrying the scopes it
|
||||
// was granted with — so connecting the calendar does not disturb a working Gmail,
|
||||
// and disconnecting one does not take the other with it.
|
||||
func (s *Server) calendarConfig(ctx context.Context) gcal.Config {
|
||||
cfg := s.gmailConfig(ctx)
|
||||
cfg.Scope = gcal.Scope
|
||||
cfg.RedirectURL = s.cfg.CalendarRedirectURL()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// --- connecting ------------------------------------------------------------------
|
||||
|
||||
// handleCalendarAuthorize starts the flow. Same shape as Gmail's, and the same
|
||||
// reason for keeping the verifier server-side: the callback arrives as a top-level
|
||||
// navigation from Google, and a SameSite=Lax cookie is the kind of thing that works
|
||||
// until a browser tightens its defaults.
|
||||
func (s *Server) handleCalendarAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
cfg := s.calendarConfig(r.Context())
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
"no Google OAuth client is registered on this server")
|
||||
return
|
||||
}
|
||||
|
||||
pkce, err := gcal.NewPKCE()
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar pkce", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Its own settings scope, not Gmail's. Two handshakes in one row would mean a
|
||||
// person who started both flows had one verifier, and whichever came back
|
||||
// second would be refused for a reason nothing on the screen could explain.
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
settings["pkce_verifier"] = pkce.Verifier
|
||||
settings["pkce_state"] = pkce.State
|
||||
// The attempt expires: a stored verifier that lives forever is a replay window
|
||||
// that lives forever.
|
||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||
}
|
||||
|
||||
// handleCalendarCallback is where Google sends the browser back.
|
||||
//
|
||||
// It answers with a redirect into the app rather than with JSON: the person is
|
||||
// looking at a browser tab, not at a fetch response.
|
||||
func (s *Server) handleCalendarCallback(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
settingsURL := s.cfg.BaseURL + "/indstillinger/integrationer"
|
||||
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
// Somebody clicked "cancel" on the consent screen — or, on an Internal
|
||||
// OAuth app, Google refused an account outside the organisation with
|
||||
// `org_internal`. Both arrive here, and the page names what it was given.
|
||||
http.Redirect(w, r, settingsURL+"?calendar="+errParam, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
if code == "" || state == "" {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=invalid", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
verifier, _ := settings["pkce_verifier"].(string)
|
||||
expected, _ := settings["pkce_state"].(string)
|
||||
expires, _ := settings["pkce_expires"].(float64)
|
||||
|
||||
// The state has to match, or this endpoint accepts a code from anywhere.
|
||||
if verifier == "" || expected == "" || state != expected {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=state", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if int64(expires) < time.Now().Unix() {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=expired", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.calendarConfig(r.Context()).Exchange(r.Context(), code,
|
||||
gcal.PKCE{Verifier: verifier})
|
||||
if err != nil {
|
||||
s.log.Error("calendar exchange", "err", err, "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
// Without one the connection dies in an hour. Google only withholds it when
|
||||
// the consent prompt was skipped, which access_type and prompt are meant to
|
||||
// prevent — so this means something is wrong with the client registration
|
||||
// rather than with this request.
|
||||
s.log.Error("calendar returned no refresh token", "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=norefresh", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// The one-time values are cleared: keeping a spent verifier serves nothing and
|
||||
// is one more secret at rest.
|
||||
delete(settings, "pkce_verifier")
|
||||
delete(settings, "pkce_state")
|
||||
delete(settings, "pkce_expires")
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "clear calendar handshake", err)
|
||||
return
|
||||
}
|
||||
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: calendarProviderName,
|
||||
RefreshToken: token.RefreshToken, AccessToken: token.AccessToken,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
}
|
||||
if err := s.db.SaveCalendarAccount(r.Context(), account); err != nil {
|
||||
s.internal(w, r, "save calendar tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The list of calendars, fetched now so the page that opens next has something
|
||||
// to tick rather than an empty panel and a button. Best effort: an account that
|
||||
// connects but cannot list itself is still connected, and the status endpoint
|
||||
// asks again when it finds nothing.
|
||||
if _, err := s.refreshCalendarList(r.Context(), account); err != nil {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
|
||||
s.log.Info("calendar connected", "user", user.ID, "account", account.Account)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=connected", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- what the settings page reads --------------------------------------------------
|
||||
|
||||
type calendarStatusJSON struct {
|
||||
Connected bool `json:"connected"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Calendars []store.Calendar `json:"calendars"`
|
||||
// RedirectURI is what has to be registered with Google, spelled out. Derived
|
||||
// from VERDANDE_BASE_URL and matched exactly — the single most likely thing to
|
||||
// be wrong, and the error Google gives for it names neither value.
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
// HasClient says whether there is an OAuth registration to connect through at
|
||||
// all, so the page can explain rather than offer a button that answers 409.
|
||||
HasClient bool `json:"has_client"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
||||
// an interface that quietly looks editable is one somebody will try to edit.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
|
||||
// Abonnementer står ved siden af Google-kontoen og ikke inde i den: man kan
|
||||
// have det ene uden det andet, og det er hele pointen med dem — en privat
|
||||
// konto, der ikke kan bruge en Internal OAuth-klient, kan abonnere.
|
||||
Subscriptions []store.CalendarAccount `json:"subscriptions"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
// Læst først, så de også kommer med, når der ingen Google-konto er.
|
||||
subscriptions, err := s.db.Subscriptions(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "subscriptions", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
|
||||
out := calendarStatusJSON{
|
||||
Calendars: []store.Calendar{},
|
||||
RedirectURI: s.cfg.CalendarRedirectURL(),
|
||||
HasClient: s.calendarConfig(r.Context()).Configured(),
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
out.Subscriptions = subscriptions
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
out.Connected = true
|
||||
out.Account = account.Account
|
||||
if !account.LastSyncAt.IsZero() {
|
||||
out.LastSyncAt = account.LastSyncAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
calendars, err := s.db.Calendars(r.Context(), account.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendars", err)
|
||||
return
|
||||
}
|
||||
// An account connected during a Google hiccup has no calendars and no way back:
|
||||
// the person sees an empty list and nothing to press. Asking again here costs
|
||||
// one request on a page nobody opens often, and means the wrong answer lasts
|
||||
// until the next look rather than for ever. Same reasoning as the Gmail address.
|
||||
if len(calendars) == 0 {
|
||||
if fetched, err := s.refreshCalendarList(r.Context(), account); err == nil {
|
||||
calendars = fetched
|
||||
out.Account = account.Account
|
||||
} else {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
if calendars != nil {
|
||||
out.Calendars = calendars
|
||||
}
|
||||
out.Subscriptions = subscriptions
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type showCalendarsRequest struct {
|
||||
// Shown is the whole set, not one flip. Same reasoning as the projects' order:
|
||||
// a person has a handful of calendars, the question is "these ones", and a list
|
||||
// that can only be built by several requests can land half applied.
|
||||
Shown []string `json:"shown"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
var req showCalendarsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
// A 404 rather than a 409: everything the caller may not see answers 404,
|
||||
// and "there is no such connection" is the true answer either way.
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no calendar is connected")
|
||||
return
|
||||
}
|
||||
if err := s.db.ShowCalendars(r.Context(), account.ID, req.Shown); err != nil {
|
||||
s.internal(w, r, "show calendars", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDisconnectCalendar forgets the connection, and the copy of the calendars
|
||||
// with it. Unlike the tasks a mailbox made, none of this is the person's own work.
|
||||
func (s *Server) handleDisconnectCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, account.ID); err != nil {
|
||||
s.internal(w, r, "disconnect calendar", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- what the grid reads -----------------------------------------------------------
|
||||
|
||||
type calendarEventsJSON struct {
|
||||
Events []store.CalendarEvent `json:"events"`
|
||||
// From and To are the window verdande holds a copy of, not the window that was
|
||||
// asked for. A grid that has paged past it gets no events, and an empty answer
|
||||
// with no explanation is a calendar quietly claiming a day is clear.
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
|
||||
fields := map[string]string{}
|
||||
if !validDay(from) {
|
||||
fields["from"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if !validDay(to) {
|
||||
fields["to"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
events, err := s.db.CalendarEvents(r.Context(), user.ID, from, to)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar events", err)
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []store.CalendarEvent{}
|
||||
}
|
||||
windowFrom, windowTo := calendarWindow(time.Now())
|
||||
writeJSON(w, http.StatusOK, calendarEventsJSON{
|
||||
Events: events, From: windowFrom, To: windowTo,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCalendarSyncNow refreshes immediately, so somebody who has just connected
|
||||
// can see it work rather than wondering whether it did.
|
||||
func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// A budget of its own, well under what sits in front of this server. An unset
|
||||
// budget is not a budget of nothing: a zero here would expire the context
|
||||
// before the first call and make every sync a silent no-op.
|
||||
budget := s.cfg.CalendarSyncBudget
|
||||
if budget <= 0 {
|
||||
budget = defaultCalendarSyncBudget
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||
defer cancel()
|
||||
|
||||
count, err := s.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Not a failure: Google is slow or busy, some calendars were read, and
|
||||
// the rest arrive on the next run. Every calendar is committed as it is
|
||||
// read, so a short budget is a shorter run rather than a lost one.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": count, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeCalendarFailed, "sync calendar", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"events": count})
|
||||
}
|
||||
|
||||
// SyncCalendars refreshes every calendar this person has chosen to see.
|
||||
//
|
||||
// Returns how many events were written. Also used by the background job, which is
|
||||
// why it takes a context and a user rather than a request.
|
||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
||||
// Abonnementer først, og deres fejl tæller ikke mod Google.
|
||||
//
|
||||
// De to har intet med hinanden at gøre ud over at ende samme sted: et
|
||||
// abonnement er en adresse, der hentes, hvor Google er et token, der udløber.
|
||||
// En kalender, hvis vært er nede, må ikke få det til at se ud som om
|
||||
// Google-forbindelsen er væk — og omvendt.
|
||||
written, err := s.syncSubscriptions(ctx, user)
|
||||
if err != nil {
|
||||
s.log.Warn("calendar subscriptions", "err", err, "user", user.ID)
|
||||
}
|
||||
|
||||
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
return written, nil // not connected; nothing to do and nothing to complain about
|
||||
}
|
||||
|
||||
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
||||
// now" during the sweep would otherwise have two runs replacing the same window
|
||||
// at once, and the loser's delete would land after the winner's insert.
|
||||
unlock := s.lockSync("calendar:" + account.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName); err == nil && fresh != nil {
|
||||
account = fresh
|
||||
}
|
||||
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
client := s.calendarClient(access)
|
||||
|
||||
// The list first, so a calendar added or renamed since the last run is drawn
|
||||
// under its new name and one that has gone stops being drawn at all.
|
||||
calendars, err := s.calendarList(ctx, client, account)
|
||||
if err != nil {
|
||||
return 0, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
for _, calendar := range calendars {
|
||||
if !calendar.Shown {
|
||||
continue
|
||||
}
|
||||
// A spent budget ends the run rather than turning into a failed fetch per
|
||||
// calendar, logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return written, ctx.Err()
|
||||
}
|
||||
events, err := client.Events(ctx, calendar.RemoteID, from, to)
|
||||
if err != nil {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
return written, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
// One calendar somebody was unshared from must not stop the others.
|
||||
s.log.Warn("calendar events", "err", err, "calendar", calendar.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
rows := make([]store.CalendarEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
rows = append(rows, store.CalendarEvent{
|
||||
RemoteID: e.ID, Summary: e.Summary,
|
||||
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||
StartDay: e.StartDay, EndDay: e.EndDay,
|
||||
AllDay: e.AllDay, Location: e.Location, URL: e.URL,
|
||||
})
|
||||
}
|
||||
if err := s.db.ReplaceCalendarEvents(ctx, calendar.ID, from, to, rows); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += len(rows)
|
||||
}
|
||||
|
||||
if err := s.db.MarkCalendarSynced(ctx, account.ID, time.Now()); err != nil {
|
||||
return written, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// calendarAccessToken returns a usable access token, refreshing only when the one
|
||||
// held has actually expired.
|
||||
func (s *Server) calendarAccessToken(ctx context.Context, account *store.CalendarAccount) (string, error) {
|
||||
if account.AccessToken != "" && account.ExpiresAt.After(time.Now()) {
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
token, err := s.calendarConfig(ctx).Refresh(ctx, account.RefreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account.AccessToken = token.AccessToken
|
||||
account.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
|
||||
// refreshCalendarList fetches the calendar list with a token it obtains itself.
|
||||
// Used by the paths that have an account but no client yet — connecting, and the
|
||||
// status endpoint correcting an empty list.
|
||||
func (s *Server) refreshCalendarList(ctx context.Context, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.calendarList(ctx, s.calendarClient(access), account)
|
||||
}
|
||||
|
||||
// calendarList writes what the provider says and returns what is stored, which is
|
||||
// not the same list: the provider says what exists, and only verdande knows which
|
||||
// of them somebody chose to look at.
|
||||
func (s *Server) calendarList(ctx context.Context, client *gcal.Client, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
remote, err := client.Calendars(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]store.Calendar, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
list = append(list, store.Calendar{
|
||||
RemoteID: c.ID, Name: c.Name, Colour: c.Colour,
|
||||
TimeZone: c.TimeZone, Primary: c.Primary, Writable: c.Owner,
|
||||
})
|
||||
// The primary calendar is the account, and its id is the address. It is the
|
||||
// only place the connected account names itself without a second scope —
|
||||
// asking for a profile scope to learn an address already in the answer would
|
||||
// be one more thing on the consent screen for nothing.
|
||||
if c.Primary && account.Account == "" {
|
||||
account.Account = c.ID
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.db.ReplaceCalendars(ctx, account.ID, list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.db.Calendars(ctx, account.ID)
|
||||
}
|
||||
|
||||
// forgetIfRevoked disconnects when Google says the grant is gone, and passes every
|
||||
// other failure through untouched.
|
||||
//
|
||||
// The distinction is the point. A revoked account polled every ten minutes for ever
|
||||
// is a log line a week long and a connection the person believes they removed; a
|
||||
// Google that is merely down is one that will answer next time.
|
||||
func (s *Server) forgetIfRevoked(ctx context.Context, user *store.User, account *store.CalendarAccount, err error) error {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
s.log.Info("calendar access was revoked; disconnecting", "user", user.ID)
|
||||
_ = s.db.DeleteCalendarAccount(ctx, user.ID, account.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// calendarWindow is the span verdande keeps a copy of, measured from today.
|
||||
//
|
||||
// Built from the calendar date rather than by truncating the instant: Truncate
|
||||
// rounds against the Unix epoch, which is midnight UTC, and in Copenhagen that is
|
||||
// two in the morning — so for two hours of every summer day the window would start
|
||||
// and end a day out.
|
||||
func calendarWindow(now time.Time) (string, string) {
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return day.AddDate(0, 0, -calendarWindowBack).Format(calendarDateLayout),
|
||||
day.AddDate(0, 0, calendarWindowAhead).Format(calendarDateLayout)
|
||||
}
|
||||
|
||||
func validDay(s string) bool {
|
||||
_, err := time.Parse(calendarDateLayout, s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// --- abonnementer -----------------------------------------------------------------
|
||||
|
||||
// maxICSBytes bounds one fetch. A published calendar of a decade of holidays is a
|
||||
// few hundred kilobytes; ten megabytes is not a calendar, and reading it into
|
||||
// memory on a schedule with nobody watching is how a homelab instance falls over
|
||||
// at three in the morning.
|
||||
const maxICSBytes = 10 << 20
|
||||
|
||||
// normaliseCalendarURL turns what somebody pastes into something that can be
|
||||
// fetched.
|
||||
//
|
||||
// `webcal://` is not a scheme anything speaks. It is a convention — the same URL
|
||||
// over https, with a scheme that makes a desktop hand it to the calendar app
|
||||
// instead of the browser — and every publisher writes their links that way. A
|
||||
// person pasting one has pasted the right address; refusing it would be refusing
|
||||
// them on a technicality.
|
||||
func normaliseCalendarURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if after, ok := strings.CutPrefix(raw, "webcal://"); ok {
|
||||
raw = "https://" + after
|
||||
}
|
||||
if !strings.HasPrefix(raw, "https://") && !strings.HasPrefix(raw, "http://") {
|
||||
return "", errors.New("must be an https:// or webcal:// address")
|
||||
}
|
||||
// The same wall the AI provider's base_url goes through, and for the same
|
||||
// reason: this is an address a user typed, and the server is about to fetch it
|
||||
// on a schedule of their choosing.
|
||||
if reason := safedial.CheckURL(raw); reason != "" {
|
||||
return "", errors.New(reason)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// icsClient is the client a subscribed calendar is fetched with.
|
||||
//
|
||||
// safedial, checked on the *resolved* address at connect time and on every
|
||||
// redirect hop — because a name that passed the check when it was pasted can
|
||||
// answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||
//
|
||||
// The field exists so a test can put an ordinary client here: httptest listens on
|
||||
// loopback, which is exactly what this refuses, so a test of the *format* cannot
|
||||
// otherwise reach its own server. It is never set outside a test, and the wall it
|
||||
// bypasses is tested where it belongs — `normaliseCalendarURL` refuses a private
|
||||
// address where it is written down, and `internal/push` proves the connect-time
|
||||
// half.
|
||||
func (s *Server) icsClient() *http.Client {
|
||||
if s.icsFetch != nil {
|
||||
return s.icsFetch
|
||||
}
|
||||
return safedial.Client(30 * time.Second)
|
||||
}
|
||||
|
||||
// fetchICS reads a subscribed calendar.
|
||||
//
|
||||
// safedial, not a plain client — checked on the resolved address at connect time
|
||||
// and on every redirect hop, because a name that passed the check when it was
|
||||
// pasted can answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||
func (s *Server) fetchICS(ctx context.Context, url string) (ics.Subscribed, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.5")
|
||||
req.Header.Set("User-Agent", "verdande/"+Version)
|
||||
|
||||
resp, err := s.icsClient().Do(req)
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return ics.Subscribed{}, fmt.Errorf("%s said %s", resp.Request.URL.Host, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1))
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
if len(body) > maxICSBytes {
|
||||
return ics.Subscribed{}, errors.New("that calendar is too large")
|
||||
}
|
||||
return ics.ParseCalendar(string(body)), nil
|
||||
}
|
||||
|
||||
// syncSubscriptions refreshes every calendar this person subscribes to by address.
|
||||
//
|
||||
// One failing address does not stop the others. A subscription is somebody else's
|
||||
// server, and the ordinary state of somebody else's server is that it is fine
|
||||
// until one morning it is not — a holiday feed that 404s must not take the
|
||||
// football club's fixtures down with it.
|
||||
func (s *Server) syncSubscriptions(ctx context.Context, user *store.User) (int, error) {
|
||||
subs, err := s.db.Subscriptions(ctx, user.ID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
var failures []string
|
||||
|
||||
for _, sub := range subs {
|
||||
if ctx.Err() != nil {
|
||||
return written, ctx.Err()
|
||||
}
|
||||
unlock := s.lockSync("calendar:" + sub.ID)
|
||||
|
||||
calendarID, err := s.db.SubscribedCalendarID(ctx, sub.ID)
|
||||
if err != nil {
|
||||
unlock()
|
||||
failures = append(failures, sub.URL)
|
||||
continue
|
||||
}
|
||||
|
||||
cal, err := s.fetchICS(ctx, sub.URL)
|
||||
if err != nil {
|
||||
unlock()
|
||||
s.log.Warn("subscribed calendar", "err", err, "url", sub.URL, "user", user.ID)
|
||||
failures = append(failures, sub.URL)
|
||||
continue
|
||||
}
|
||||
|
||||
// The name the file calls itself, once. A subscription is added under its
|
||||
// address because that is all there is to go on before it has been fetched.
|
||||
if err := s.db.RenameSubscribedCalendar(ctx, sub.ID, cal.Name); err != nil {
|
||||
s.log.Warn("name subscribed calendar", "err", err)
|
||||
}
|
||||
|
||||
rows := make([]store.CalendarEvent, 0, len(cal.Events))
|
||||
for _, e := range cal.Events {
|
||||
// Outside the window is not stored. The grid asks about a window, the
|
||||
// sweep replaces a window, and a decade of holidays in one file would
|
||||
// otherwise be a decade of rows rewritten every fifteen minutes.
|
||||
if e.EndDay < from || e.StartDay > to {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, store.CalendarEvent{
|
||||
RemoteID: e.UID, Summary: e.Summary, Location: e.Location, URL: e.URL,
|
||||
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||
StartDay: e.StartDay, EndDay: e.EndDay, AllDay: e.AllDay,
|
||||
})
|
||||
}
|
||||
if err := s.db.ReplaceCalendarEvents(ctx, calendarID, from, to, rows); err != nil {
|
||||
unlock()
|
||||
return written, err
|
||||
}
|
||||
if err := s.db.MarkCalendarSynced(ctx, sub.ID, time.Now()); err != nil {
|
||||
s.log.Warn("mark subscription synced", "err", err)
|
||||
}
|
||||
written += len(rows)
|
||||
unlock()
|
||||
}
|
||||
|
||||
if len(failures) == len(subs) {
|
||||
return written, fmt.Errorf("none of the %d subscribed calendars could be read", len(subs))
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// handleSubscribeCalendar adds a calendar somebody only has an address to.
|
||||
//
|
||||
// Fetched once, here, before it is stored. An address that cannot be read is a
|
||||
// typo far more often than it is a server that happens to be down, and telling
|
||||
// somebody now — while they are looking at the field they just pasted into — is
|
||||
// worth the two seconds. A subscription that silently never fills is the same
|
||||
// silence the notifications had.
|
||||
func (s *Server) handleSubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
url, err := normaliseCalendarURL(req.URL)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"url": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cal, err := s.fetchICS(ctx, url)
|
||||
if err != nil {
|
||||
s.log.Warn("subscribe calendar", "err", err, "user", user.ID)
|
||||
writeFieldErrors(w, map[string]string{"url": "that address could not be read"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(cal.Name)
|
||||
}
|
||||
if name == "" {
|
||||
name = url
|
||||
}
|
||||
|
||||
account, err := s.db.SubscribeCalendar(r.Context(), user.ID, url, name)
|
||||
if err != nil {
|
||||
// The unique index is the one refusal worth naming: subscribing twice to
|
||||
// the same address is the same calendar fetched twice, with no way to say
|
||||
// which one counts.
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeFieldErrors(w, map[string]string{"url": "that calendar is already subscribed"})
|
||||
return
|
||||
}
|
||||
s.storeError(w, r, "subscribe calendar", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The events it already fetched, rather than making the person wait fifteen
|
||||
// minutes for the sweep to prove it worked.
|
||||
if _, err := s.syncSubscriptions(r.Context(), user); err != nil {
|
||||
s.log.Warn("first subscription sync", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, account)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnsubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, chi.URLParam(r, "id")); err != nil {
|
||||
s.storeError(w, r, "unsubscribe calendar", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// fakeCalendarGoogle stands in for the parts of Google this feature touches: the
|
||||
// token endpoint, the calendar list and one calendar's events.
|
||||
//
|
||||
// There is no other way to exercise any of it. The real thing needs a registered
|
||||
// OAuth client, an account and somebody's consent, and none of the three can be had
|
||||
// inside a test — so the shapes Google returns are written down and the behaviour
|
||||
// is checked against them, the way internal/imap is checked against go-imap's own
|
||||
// in-process server.
|
||||
type fakeCalendarGoogle struct {
|
||||
*httptest.Server
|
||||
// refreshes counts token refreshes, so a test can prove one is not made for a
|
||||
// token that has not expired.
|
||||
refreshes int
|
||||
// refuse makes every API call answer 401, which is what a revoked grant does.
|
||||
refuse bool
|
||||
events map[string][]map[string]any
|
||||
}
|
||||
|
||||
func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle {
|
||||
t.Helper()
|
||||
g := &fakeCalendarGoogle{events: map[string][]map[string]any{}}
|
||||
g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
g.refreshes++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "ya29.fresh", "refresh_token": "1//0-refresh",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
case g.refuse:
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{
|
||||
{"id": "[email protected]", "summary": "[email protected]", "primary": true,
|
||||
"accessRole": "owner", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen"},
|
||||
{"id": "fam", "summary": "Familien", "accessRole": "reader",
|
||||
"backgroundColor": "#0b8043"},
|
||||
}})
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
items := g.events[id]
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(g.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
// connectedCalendar returns a server with a calendar account already connected —
|
||||
// the state the OAuth callback leaves behind, without the consent screen nothing
|
||||
// can automate.
|
||||
func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) {
|
||||
t.Helper()
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = g.URL + "/token"
|
||||
cfg.CalendarAPIURL = g.URL
|
||||
if tune != nil {
|
||||
tune(cfg)
|
||||
}
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh",
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts, user
|
||||
}
|
||||
|
||||
// The scope on the consent screen is what somebody agrees to. It has to be the
|
||||
// read-only one, and the redirect has to be the calendar's own — sharing Gmail's
|
||||
// would put "which token store do I write" behind a lookup.
|
||||
func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
url, _ := body["url"].(string)
|
||||
for _, want := range []string{
|
||||
"scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
|
||||
"%2Foauth%2Fcalendar%2Fcallback",
|
||||
"access_type=offline",
|
||||
"prompt=consent",
|
||||
} {
|
||||
if !strings.Contains(url, want) {
|
||||
t.Errorf("the authorisation URL is missing %q:\n%s", want, url)
|
||||
}
|
||||
}
|
||||
if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") {
|
||||
t.Errorf("a writing scope was asked for:\n%s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a registered client there is nothing to connect through, and the answer
|
||||
// has to say that rather than "that already exists".
|
||||
func TestAuthorizeWithoutAClientSaysSo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeGmailNotConfigured {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole path, minus the consent screen: sync fetches the list, refreshes the
|
||||
// events of what was chosen, and the grid endpoint answers with them.
|
||||
func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}},
|
||||
}
|
||||
g.events["[email protected]"] = []map[string]any{
|
||||
{"id": "e2", "summary": "Skal ikke med",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
// The list arrives on the first look at the settings page, which is what
|
||||
// somebody would be doing at this point.
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["connected"] != true {
|
||||
t.Fatalf("not reported as connected: %v", body)
|
||||
}
|
||||
if body["read_only"] != true {
|
||||
t.Error("the connection did not say it is read-only; an interface that looks " +
|
||||
"editable is one somebody will try to edit")
|
||||
}
|
||||
// The primary calendar's id is the address, which is how the account names
|
||||
// itself without asking for a profile scope as well.
|
||||
if body["account"] != "[email protected]" {
|
||||
t.Errorf("account = %v", body["account"])
|
||||
}
|
||||
|
||||
list, _ := body["calendars"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"])
|
||||
}
|
||||
var famID string
|
||||
for _, raw := range list {
|
||||
c := raw.(map[string]any)
|
||||
if c["shown"] == true {
|
||||
t.Errorf("%v was shown without anybody choosing it", c["name"])
|
||||
}
|
||||
if c["remote_id"] == "fam" {
|
||||
famID = c["id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is chosen, so a sync writes nothing: an account with a dozen shared
|
||||
// calendars must not fill somebody's grid the moment it is connected.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["events"] != float64(0) {
|
||||
t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars",
|
||||
map[string]any{"shown": []string{famID}})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("choosing a calendar: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["events"] != float64(1) {
|
||||
t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET",
|
||||
"/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("events: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
events, _ := body["events"].([]any)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1: %v", len(events), body["events"])
|
||||
}
|
||||
event := events[0].(map[string]any)
|
||||
if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" {
|
||||
t.Errorf("the event came back as %v", event)
|
||||
}
|
||||
// The calendar's colour rides along, or the chip has to ask for it per row.
|
||||
if event["colour"] != "#0b8043" {
|
||||
t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"])
|
||||
}
|
||||
// The window verdande holds is stated, so a grid paged past it can say so
|
||||
// rather than showing an empty month it cannot explain.
|
||||
if body["from"] == "" || body["to"] == "" {
|
||||
t.Error("the answer does not say which window it covers")
|
||||
}
|
||||
|
||||
// And it is one person's calendar. Somebody else on the same instance sees
|
||||
// nothing of it.
|
||||
other := ts.newUser(t, "[email protected]", "Andreas")
|
||||
_, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("somebody else's calendar was visible: %v", body["events"])
|
||||
}
|
||||
_ = user
|
||||
}
|
||||
|
||||
// A token that has not expired must not be traded in for another. Refreshing on
|
||||
// every run burns the quota and makes the failure mode of a bad refresh token
|
||||
// arrive on every sweep rather than once an hour.
|
||||
func TestAFreshTokenIsNotRefreshed(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account.AccessToken = "ya29.still-good"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 0 {
|
||||
t.Errorf("the token was refreshed %d times while it was still good", g.refreshes)
|
||||
}
|
||||
|
||||
// And an expired one is.
|
||||
account.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 1 {
|
||||
t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes)
|
||||
}
|
||||
}
|
||||
|
||||
// Access revoked at Google's end is a connection that has ended, not a failure to
|
||||
// retry. Polling it every fifteen minutes for ever is a week of log lines and a
|
||||
// connection the person believes they removed.
|
||||
func TestRevokedAccessDisconnects(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
account.AccessToken = "ya29.revoked"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.refuse = true
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Fatalf("status %d, want %d — Google saying no is not this server breaking",
|
||||
resp.StatusCode, StatusUpstreamRefused)
|
||||
}
|
||||
if body["code"] != CodeCalendarFailed {
|
||||
t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+
|
||||
"which throws the diagnosis away", body["code"], CodeCalendarFailed)
|
||||
}
|
||||
|
||||
gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gone != nil {
|
||||
t.Error("the connection is still there after Google refused the token")
|
||||
}
|
||||
|
||||
// And the failure is in the error log, which is the point of having one: a
|
||||
// sync that has been failing for a week is invisible otherwise.
|
||||
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := logged["errors"].([]any)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("nothing was recorded")
|
||||
}
|
||||
if row := rows[0].(map[string]any); row["what"] != "sync calendar" {
|
||||
t.Errorf("what = %v, want \"sync calendar\"", row["what"])
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the copy with it. None of it is the person's own work, and
|
||||
// a calendar left behind after the account is gone is a frozen lie about a day.
|
||||
func TestDisconnectingRemovesTheEvents(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Møde",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
calendars, _ := ts.db.Calendars(t.Context(), account.ID)
|
||||
var famID string
|
||||
for _, c := range calendars {
|
||||
if c.RemoteID == "fam" {
|
||||
famID = c.ID
|
||||
}
|
||||
}
|
||||
ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}})
|
||||
ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disconnect: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("events survived the disconnect: %v", body["events"])
|
||||
}
|
||||
_, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if body["connected"] != false {
|
||||
t.Error("still reported as connected")
|
||||
}
|
||||
}
|
||||
|
||||
// A range that is not a date has to be refused, not guessed at. An empty `from`
|
||||
// compared as a string matches everything before it, which is a grid quietly
|
||||
// answering a question nobody asked.
|
||||
func TestEventsRefuseARangeThatIsNotADate(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
for _, query := range []string{
|
||||
"", "?from=2026-08-01", "?from=&to=2026-08-31",
|
||||
"?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40",
|
||||
} {
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A person with no calendar connected still opens the view. It has to answer, and
|
||||
// answer emptily, rather than fail — the grid is also verdande's own tasks, and
|
||||
// they must not disappear because Google is not set up.
|
||||
func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if events, ok := body["events"].([]any); !ok || len(events) != 0 {
|
||||
t.Errorf("events = %v, want an empty list rather than null", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["connected"] != false {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["has_client"] != false {
|
||||
t.Error("an instance with no OAuth registration claimed to have one; the page " +
|
||||
"would offer a button that answers 409")
|
||||
}
|
||||
// Spelled out even when nothing is connected: it is what has to be registered
|
||||
// with Google, and it is the single most likely thing to be wrong.
|
||||
if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") {
|
||||
t.Errorf("redirect_uri = %q", uri)
|
||||
}
|
||||
}
|
||||
|
||||
// A slow Google must not hold the request open until something in front of this
|
||||
// server gives up on it and answers with its own HTML.
|
||||
func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) {
|
||||
blocked := make(chan struct{})
|
||||
defer close(blocked)
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer slow.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = slow.URL + "/token"
|
||||
cfg.CalendarAPIURL = slow.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
// would put half a minute into the suite to learn the same thing.
|
||||
cfg.CalendarSyncBudget = time.Second
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "r",
|
||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own client, because the shared one gives up after ten seconds — which is
|
||||
// shorter than the budget being tested and would prove nothing either way.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/calendar/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s); the sync has no budget of its "+
|
||||
"own, and whatever sits in front of this server answers for it",
|
||||
err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if took := time.Since(started); took > 10*time.Second {
|
||||
t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -225,17 +225,32 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
a := &store.Attachment{
|
||||
TaskID: taskID,
|
||||
CommentID: r.FormValue("comment_id"),
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
|
||||
// An attachment hangs on a task or on a comment, never both — the schema says
|
||||
// so, and the form may have supplied a comment id.
|
||||
if a.CommentID != "" {
|
||||
a.TaskID = ""
|
||||
//
|
||||
// The comment is resolved and checked against the task in the URL. It was
|
||||
// taken on trust, which meant the permission check above guarded the *task
|
||||
// somebody named* while the file landed on *whatever comment they asked for*.
|
||||
// A viewer on a shared project can list its comments, so she could read an id
|
||||
// there and then post to a task in her own project with that id attached: the
|
||||
// file appears under somebody else's comment, in a project she may only read,
|
||||
// and is served to everyone in it from this origin under a name she chose.
|
||||
// Somebody removed from a project could do the same with ids they kept.
|
||||
if id := r.FormValue("comment_id"); id != "" {
|
||||
owner, err := s.db.CommentTask(r.Context(), id)
|
||||
if err != nil || owner != taskID {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
a.CommentID, a.TaskID = id, ""
|
||||
}
|
||||
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
@@ -394,12 +409,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
||||
// HTML file displayed inline would run its own script on this origin, with the
|
||||
// session cookie attached — attachments are the one place a user supplies
|
||||
// content that another user opens.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
||||
// Almost everything is served as a download rather than rendered. An uploaded
|
||||
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||
// with the session cookie attached — attachments are the one place a user
|
||||
// supplies content that another user opens.
|
||||
//
|
||||
// The exception is a short list of raster images, and it is not a softening of
|
||||
// that rule but what makes a note work: a note carries its pictures in the text
|
||||
// as ``, and served as octet-stream every one of them is a broken image.
|
||||
// The whole Apple Notes import exists to bring those pictures along.
|
||||
//
|
||||
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||
// type that can run script.
|
||||
disposition := "attachment"
|
||||
contentType := "application/octet-stream"
|
||||
if inlineImage[a.MimeType] {
|
||||
disposition = "inline"
|
||||
contentType = a.MimeType
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||
@@ -408,6 +441,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// inlineImage is what may be shown rather than downloaded.
|
||||
//
|
||||
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||
// can run script, and a list that grows by accident is how that happens.
|
||||
var inlineImage = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/avif": true,
|
||||
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||
//
|
||||
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
|
||||
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
|
||||
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
|
||||
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
|
||||
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
policy := resp.Header.Get("Content-Security-Policy")
|
||||
if policy == "" {
|
||||
t.Fatal("der er ingen Content-Security-Policy på svaret")
|
||||
}
|
||||
|
||||
// Målt på script-src alene, og ikke på hele politikken.
|
||||
//
|
||||
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
|
||||
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
|
||||
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
|
||||
// bliver til et kørende script, og det er derfor den, prøven ser på.
|
||||
scriptSrc := ""
|
||||
for _, part := range strings.Split(policy, ";") {
|
||||
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
|
||||
scriptSrc = part
|
||||
}
|
||||
}
|
||||
if scriptSrc == "" {
|
||||
t.Fatal("der er ingen script-src i politikken")
|
||||
}
|
||||
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
|
||||
if strings.Contains(scriptSrc, forbidden) {
|
||||
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
|
||||
}
|
||||
}
|
||||
|
||||
// Og de fire, der skal være der.
|
||||
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
|
||||
if !strings.Contains(policy, want) {
|
||||
t.Errorf("politikken mangler %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HSTS følger med, når instansen kører over HTTPS — og kun der.
|
||||
//
|
||||
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
|
||||
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
|
||||
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
|
||||
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
|
||||
func TestHSTSTracksHTTPS(t *testing.T) {
|
||||
overHTTP := newTestServer(t) // BaseURL er http://localhost
|
||||
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("HSTS sat over ren HTTP: %q", got)
|
||||
}
|
||||
|
||||
overHTTPS := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://todo.example.dk"
|
||||
})
|
||||
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
|
||||
t.Error("ingen HSTS over HTTPS")
|
||||
} else if !strings.Contains(got, "includeSubDomains") {
|
||||
t.Errorf("HSTS uden includeSubDomains: %q", got)
|
||||
}
|
||||
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
|
||||
// mikrofon og placering, som appen aldrig beder om.
|
||||
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
|
||||
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
|
||||
}
|
||||
// COOP isolerer browserkonteksten og står på hvert svar.
|
||||
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
|
||||
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Et bilag må ikke kunne blive læst som et dokument.
|
||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||
if inlineImage[mime] {
|
||||
t.Errorf("%s står på listen over det, der vises inline", mime)
|
||||
}
|
||||
}
|
||||
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
|
||||
for _, mime := range []string{"image/png", "image/jpeg"} {
|
||||
if !inlineImage[mime] {
|
||||
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,11 @@ const (
|
||||
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
||||
// is not there — and it says it in its own words.
|
||||
CodeMailboxFailed = "mailbox_failed"
|
||||
|
||||
// And for a calendar read from Google. Its own code rather than the Gmail one,
|
||||
// because the sentence the interface wraps it in names where it came from — and
|
||||
// "Gmail said no" about a calendar sends somebody to the wrong panel.
|
||||
CodeCalendarFailed = "calendar_failed"
|
||||
)
|
||||
|
||||
// errLastAdmin is returned to the caller that already wrote the response, so it
|
||||
|
||||
@@ -25,18 +25,16 @@ const defaultGmailSyncBudget = 25 * time.Second
|
||||
// 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,
|
||||
})
|
||||
return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL)
|
||||
}
|
||||
|
||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
||||
cfg := gmail.Config{
|
||||
ClientID: s.cfg.GmailClientID,
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
Endpoints: endpoints,
|
||||
Scope: gmail.Scope,
|
||||
TokenURL: s.cfg.GoogleTokenURL,
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||
return cfg
|
||||
@@ -251,6 +249,19 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns how many were made. Also used by the background job, which is why it
|
||||
// takes a context and a user rather than a request.
|
||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||
//
|
||||
// The star is taken off an imported message now, so on a starred trigger the mail
|
||||
// leaves the query on its own and this list is a safety net rather than the only
|
||||
// record. It still earns its place: it covers the window between making the task
|
||||
// and the star coming off, and it is the whole of the dedup for a label trigger,
|
||||
// where the label is the person's and not ours to remove. A record on this side
|
||||
// has to be bounded.
|
||||
//
|
||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||
// which was not a bound but a way of forgetting.
|
||||
const maxGmailSeen = 2000
|
||||
|
||||
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
@@ -260,7 +271,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// The same guard as the mailboxes read over IMAP, for the same reason: a
|
||||
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||
// one marker.
|
||||
unlock := s.lockMailbox(box.ID)
|
||||
unlock := s.lockSync(box.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||
box = fresh
|
||||
@@ -351,8 +362,19 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
Description: msg.Snippet + "\n\n" + msg.Link,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
// The thread, not the message: a reply arriving later is the same
|
||||
// conversation, and a second task for it would be the duplicate this
|
||||
// key exists to prevent.
|
||||
SourceKey: "gmail:thread:" + msg.ThreadID,
|
||||
}
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Somebody got there first — an earlier sweep, or another
|
||||
// assistant reading the same mailbox. Mark it seen and move on;
|
||||
// it is handled, which is all this loop wanted.
|
||||
seen[id] = true
|
||||
continue
|
||||
}
|
||||
s.log.Warn("gmail create task", "err", err)
|
||||
continue
|
||||
}
|
||||
@@ -361,15 +383,59 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// Only the ids Gmail still returns are kept, so the list cannot grow
|
||||
// without bound as messages fall out of the 30-day window.
|
||||
keep := make([]string, 0, len(ids))
|
||||
// The star comes off every message this query returned that now has a task —
|
||||
// the ones made this run and the ones a run before it already made. Gated to the
|
||||
// triggers that are about the star: for a label trigger the star is the person's
|
||||
// own and not ours to touch, and dedup there rests on the seen list alone.
|
||||
//
|
||||
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
|
||||
// that would not come off. The likeliest reason it will not, right after this
|
||||
// ships, is a connection still holding the old read-only grant — the write is a
|
||||
// 403 until the account is reconnected, and this logs it rather than failing the
|
||||
// sync. It self-heals: the mail stays starred, stays in the next query, and is
|
||||
// tried again once the grant is there.
|
||||
if box.Trigger == "starred" || box.Trigger == "both" {
|
||||
unstar := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
keep = append(keep, id)
|
||||
unstar = append(unstar, id)
|
||||
}
|
||||
}
|
||||
if err := client.Unstar(ctx, unstar); err != nil {
|
||||
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
|
||||
}
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// The ids this run saw, on top of the ones already known.
|
||||
//
|
||||
// It used to keep only the ids Gmail returned *this run*, to stop the list
|
||||
// growing without bound. That threw away every id that was not on the page
|
||||
// it happened to fetch — and the query is capped, so on an account with more
|
||||
// starred mail than one page, each run forgot the messages the last one had
|
||||
// remembered. They came back unseen, and became tasks again, and again. That
|
||||
// is the duplicate.
|
||||
//
|
||||
// Bounded by a count instead. Newest first, and a ceiling far above any
|
||||
// plausible page, so an id leaves the list because it is old rather than
|
||||
// because it was not in the last answer.
|
||||
keep := make([]string, 0, len(ids)+len(box.Seen))
|
||||
added := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if seen[id] && !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range box.Seen {
|
||||
if !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
if len(keep) > maxGmailSeen {
|
||||
keep = keep[:maxGmailSeen]
|
||||
}
|
||||
box.Seen = keep
|
||||
box.LastSyncAt = time.Now()
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
@@ -401,8 +467,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// The one click that opens Google's consent screen already exists; what it needs
|
||||
// behind it is a registered client, and that is not something any app can conjure:
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
|
||||
// a restricted scope, so an id shipped inside a public image would be both
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||
// restricted scope, so an id shipped inside a public image would be both
|
||||
// extractable from it and unusable without Google's security review of every
|
||||
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
||||
// recreate the container to paste the result was not.
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// A Gmail failure has to survive being reported.
|
||||
@@ -29,7 +32,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
@@ -97,7 +100,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
// A message that becomes a task has its star taken off — the write that the whole
|
||||
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
|
||||
// the way the IMAP side unflags what it imports.
|
||||
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
|
||||
var unstarred struct {
|
||||
hit bool
|
||||
body struct {
|
||||
IDs []string `json:"ids"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}
|
||||
}
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
|
||||
unstarred.hit = true
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &unstarred.body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
|
||||
writeTestJSON(w, map[string]any{
|
||||
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
|
||||
"payload": map[string]any{"headers": []map[string]string{
|
||||
{"name": "From", "value": "Anders <[email protected]>"},
|
||||
{"name": "Subject", "value": "Møde på fredag"},
|
||||
}},
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/messages"):
|
||||
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A live access token, so the run goes straight to the list without a refresh.
|
||||
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if created, _ := body["created"].(float64); created != 1 {
|
||||
t.Fatalf("created = %v, want 1", body["created"])
|
||||
}
|
||||
|
||||
if !unstarred.hit {
|
||||
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
|
||||
}
|
||||
if strings.Join(unstarred.body.IDs, ",") != "m1" {
|
||||
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
|
||||
}
|
||||
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
|
||||
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -214,6 +215,35 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The address is checked where it is typed, as well as when it is dialled. A
|
||||
// scheme other than http or https is refused outright, for everybody —
|
||||
// file:// and gopher:// are not providers.
|
||||
//
|
||||
// The private-network wall has one exception, and it is the point of the
|
||||
// provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model
|
||||
// on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150.
|
||||
// safedial refuses exactly those, so the address the field suggested in its
|
||||
// own placeholder was an address the server would not accept.
|
||||
//
|
||||
// Admins only, because the wall is there for a real reason: a server that
|
||||
// fetches a user-supplied address lets the person supplying it reach what
|
||||
// they cannot reach themselves. An admin on a self-hosted instance already
|
||||
// owns the host and everything on its network, so this grants them nothing
|
||||
// new. An invited user is a different person, and for them it stays shut.
|
||||
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin {
|
||||
if reason := safedial.CheckURL(url); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
req.BaseURL = url
|
||||
}
|
||||
|
||||
existing, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "ai settings", err)
|
||||
@@ -246,11 +276,20 @@ func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
}
|
||||
// Worked out per request rather than stored beside the address: it answers
|
||||
// "may this person reach the private network", and somebody who is no longer
|
||||
// an admin should stop being able to. A flag written at save time would
|
||||
// outlive the role that granted it.
|
||||
allowPrivate := false
|
||||
if u := userFrom(r.Context()); u != nil && u.IsAdmin {
|
||||
allowPrivate = true
|
||||
}
|
||||
return ai.Config{
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
AllowPrivate: allowPrivate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,18 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// --- MCP -----------------------------------------------------------------------------
|
||||
@@ -250,6 +255,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
|
||||
return token
|
||||
}
|
||||
|
||||
// The promise of sharing has to hold at every door, not just the API's.
|
||||
//
|
||||
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
|
||||
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
|
||||
// authorised differently: it checked the caller's standing in the project *named
|
||||
// in the URL* and then took the task id from the URL as well. Nothing tied the
|
||||
// two together.
|
||||
//
|
||||
// So a viewer on somebody else's project could name a project of their own —
|
||||
// their Inbox, which everybody owns and everybody may edit — and put the other
|
||||
// project's task id in the filename. The project check passed on the Inbox, and
|
||||
// the write landed on a task in a project they may only read.
|
||||
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "[email protected]", "Viewer")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "en opgave", "project_id": projectID,
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "viewer",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
them, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, them.ID)
|
||||
|
||||
// Their own Inbox: a project they own and may edit, and the one every account
|
||||
// has. It is what makes the URL check pass.
|
||||
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var inbox string
|
||||
for _, p := range projects {
|
||||
if p.IsInbox {
|
||||
inbox = p.ID
|
||||
}
|
||||
}
|
||||
if inbox == "" {
|
||||
t.Fatal("the viewer has no inbox")
|
||||
}
|
||||
|
||||
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
|
||||
token, them.Email, vtodo)
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And the task is as its owner left it. The status alone is not the assertion:
|
||||
// the question is whether anything moved.
|
||||
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if after["content"] != "en opgave" {
|
||||
t.Errorf("the task was renamed by a viewer: %v", after["content"])
|
||||
}
|
||||
if after["completed"] != false {
|
||||
t.Error("the task was completed by a viewer")
|
||||
}
|
||||
}
|
||||
|
||||
// A task may only be addressed under the project it is actually in.
|
||||
//
|
||||
// The same hole seen from the other side: an editor on two projects could reach a
|
||||
// task in one of them through the other's URL. Nothing is escalated by it — they
|
||||
// may edit both — but it is the check that was missing, and a URL that lies about
|
||||
// where a resource lives is how the viewer case got in.
|
||||
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, user.ID)
|
||||
|
||||
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
|
||||
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "hører til Et", "project_id": one["id"].(string),
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
|
||||
token, user.Email, vtodo)
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if after["content"] != "hører til Et" {
|
||||
t.Errorf("the task changed: %v", after["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalDAVRequiresCredentials(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -562,6 +677,128 @@ func TestAIKeyIsNeverReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting something else must not delete the AI provider.
|
||||
//
|
||||
// Reported from use as "the AI settings are not saved": they were saved, and then
|
||||
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
|
||||
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
|
||||
// one row and each write deleted the other two. Every write returned 204.
|
||||
//
|
||||
// Through the handlers, because that is where it was seen. The shape is asserted
|
||||
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
|
||||
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
|
||||
})
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the calendar and the mailbox handlers do when a handshake starts: write
|
||||
// their own scope for the same person.
|
||||
for _, scope := range []string{"calendar", "gmail"} {
|
||||
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
|
||||
map[string]any{"pkce_state": "s-" + scope}); err != nil {
|
||||
t.Fatalf("save %s: %v", scope, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||
if body["provider"] != "anthropic" {
|
||||
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
|
||||
}
|
||||
if body["model"] != "claude-sonnet-5" {
|
||||
t.Errorf("the model is %v", body["model"])
|
||||
}
|
||||
if body["has_key"] != true {
|
||||
t.Error("the key was taken with it")
|
||||
}
|
||||
}
|
||||
|
||||
// Reported from use as "the AI settings are not saved" — the second time, and a
|
||||
// different cause than the one above.
|
||||
//
|
||||
// The provider is called "OpenAI-kompatibel (Ollama, LM Studio, …)" and the
|
||||
// address field suggests http://localhost:11434/v1. Both of those live on the
|
||||
// private network, which safedial refuses — so the address the interface proposed
|
||||
// was the address the server rejected, and the one provider that exists for
|
||||
// running your own model could not be configured at all.
|
||||
//
|
||||
// It looked like a save that did nothing, because the 422 came back on a field
|
||||
// with nowhere to show it. Two bugs wearing one coat.
|
||||
func TestAdminMayPointAIAtAModelOnTheLAN(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// The three shapes a self-hosted model actually takes: another machine on the
|
||||
// LAN, the loopback name, and the loopback address.
|
||||
for _, addr := range []string{
|
||||
"http://192.168.1.150:8080/v1",
|
||||
"http://localhost:11434/v1",
|
||||
"http://127.0.0.1:1234/v1",
|
||||
} {
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "gemma-4-26b-qat", "base_url": addr,
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("save %s: status %d, body %v", addr, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||
if body["base_url"] != "http://127.0.0.1:1234/v1" {
|
||||
t.Errorf("base_url = %v after saving, want it to have survived", body["base_url"])
|
||||
}
|
||||
if body["provider"] != "compatible" {
|
||||
t.Errorf("provider = %v", body["provider"])
|
||||
}
|
||||
}
|
||||
|
||||
// The wall stays up for everybody else.
|
||||
//
|
||||
// An admin on a self-hosted instance already owns the host and its network, so a
|
||||
// private destination grants them nothing they did not have. An invited user is a
|
||||
// different person, and for them the address field would be a way to find out
|
||||
// what answers on the host's network.
|
||||
func TestAnInvitedUserCannotPointAIAtThePrivateNetwork(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Andreas")
|
||||
|
||||
resp, body := other.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "llama3.1",
|
||||
"base_url": "http://192.168.1.150:8080/v1",
|
||||
})
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatal("an invited user was allowed to point the AI at the private network")
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["base_url"] == nil {
|
||||
t.Errorf("the refusal did not name the field it was about: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A scheme that is not http or https is refused for everybody, admin or not.
|
||||
func TestAISettingsRefuseANonHTTPAddress(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "llama3.1", "base_url": "file:///etc/passwd",
|
||||
})
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatal("file:// was accepted as a provider address")
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["base_url"] == nil {
|
||||
t.Errorf("no error on base_url: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSettings(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -1316,3 +1553,172 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||
t.Error("the second rotation returned the same address")
|
||||
}
|
||||
}
|
||||
|
||||
// "Der kommer ingen notifikationer" må kunne besvares.
|
||||
//
|
||||
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
|
||||
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
|
||||
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
|
||||
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
|
||||
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
|
||||
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
|
||||
"title": "Prøve", "body": "kom den frem?",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["subscriptions"] != float64(0) {
|
||||
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
|
||||
}
|
||||
|
||||
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
|
||||
// som et navngivent afslag og ikke som tavshed.
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
|
||||
UserID: user.ID,
|
||||
Endpoint: "https://push.example.invalid/abc",
|
||||
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
|
||||
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
|
||||
if body["subscriptions"] != float64(1) {
|
||||
t.Fatalf("subscriptions = %v", body["subscriptions"])
|
||||
}
|
||||
failed, _ := body["failed"].([]any)
|
||||
if len(failed) != 1 {
|
||||
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
|
||||
}
|
||||
first, _ := failed[0].(map[string]any)
|
||||
if first["service"] != "push.example.invalid" {
|
||||
t.Errorf("service = %v", first["service"])
|
||||
}
|
||||
if first["reason"] == "" || first["reason"] == nil {
|
||||
t.Error("afslaget kom uden en grund")
|
||||
}
|
||||
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
|
||||
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
|
||||
if strings.Contains(fmt.Sprint(first), "/abc") {
|
||||
t.Errorf("endepunktet lækkede med ud: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// Et abonnement på en kalender, man kun har en adresse til.
|
||||
//
|
||||
// Bedt om som "abonnering af flere kalendere (ics og webcal?)". Google-vejen er
|
||||
// OAuth og én konto pr. person; det her er en adresse, og man har flere. Det er
|
||||
// også den eneste vej for en privat konto, som en Internal-registrering afviser
|
||||
// med `org_internal`.
|
||||
//
|
||||
// Prøven går uden om handleren og ind i det, den kalder. Grunden er væggen:
|
||||
// `httptest` lytter på loopback, og safedial nægter — både når adressen skrives
|
||||
// ned og igen når den hentes. Det er rigtigt, og det skal ikke kunne slås fra fra
|
||||
// en prøve. Så adressereglerne prøves for sig i TestWebcalIsRewrittenToHTTPS, og
|
||||
// her prøves resten af vejen: hentning, format, navn, gitteret og afmeldingen.
|
||||
func TestSubscribingToACalendarByAddress(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
day := time.Now().AddDate(0, 0, 2).Format("20060102")
|
||||
feedBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Helligdage\r\n" +
|
||||
"BEGIN:VEVENT\r\nUID:[email protected]\r\n" +
|
||||
"DTSTART;VALUE=DATE:" + day + "\r\nDTEND;VALUE=DATE:" + day + "\r\n" +
|
||||
"SUMMARY:Grundlovsdag\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
var hits int
|
||||
feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "text/calendar")
|
||||
w.Write([]byte(feedBody))
|
||||
}))
|
||||
defer feed.Close()
|
||||
ts.api.icsFetch = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Tilføjet under sin adresse, som er alt der er at gå efter, før den er hentet.
|
||||
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, feed.URL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ts.api.syncSubscriptions(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits == 0 {
|
||||
t.Fatal("adressen blev aldrig hentet")
|
||||
}
|
||||
|
||||
// Navnet kommer fra filen. X-WR-CALNAME er det, alle udgivere bruger; der
|
||||
// findes ingen standardegenskab for det.
|
||||
_, status := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
subs, _ := status["subscriptions"].([]any)
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("%d abonnementer i svaret", len(subs))
|
||||
}
|
||||
first, _ := subs[0].(map[string]any)
|
||||
if first["account"] != "Helligdage" {
|
||||
t.Errorf("navnet er %v, want Helligdage", first["account"])
|
||||
}
|
||||
|
||||
// Og begivenheden står i gitteret.
|
||||
from := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
to := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
||||
_, events := ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||
list, _ := events["events"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("%d begivenheder efter abonnementet", len(list))
|
||||
}
|
||||
if e, _ := list[0].(map[string]any); e["summary"] != "Grundlovsdag" {
|
||||
t.Errorf("begivenheden er %v", e["summary"])
|
||||
}
|
||||
|
||||
// Den samme adresse to gange er den samme kalender hentet to gange, og der er
|
||||
// ingen måde at sige hvilken der gælder.
|
||||
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, ""); !errors.Is(err, store.ErrDuplicate) {
|
||||
t.Errorf("den samme adresse blev accepteret to gange: %v", err)
|
||||
}
|
||||
|
||||
// Fjernet igen, og begivenhederne går med.
|
||||
resp, _ := ts.do(t, "DELETE", "/api/v1/calendar/subscriptions/"+first["id"].(string), nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unsubscribe: status %d", resp.StatusCode)
|
||||
}
|
||||
_, events = ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||
if list, _ := events["events"].([]any); len(list) != 0 {
|
||||
t.Errorf("%d begivenheder blev tilbage efter afmeldingen", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// webcal:// er https med et andet skema foran. Det er ikke en protokol, noget
|
||||
// taler — det er en konvention, der får et skrivebord til at give adressen til
|
||||
// kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres
|
||||
// links sådan. At afvise den ville være at afvise folk på en teknikalitet.
|
||||
func TestWebcalIsRewrittenToHTTPS(t *testing.T) {
|
||||
got, err := normaliseCalendarURL("webcal://example.dk/kalender.ics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "https://example.dk/kalender.ics" {
|
||||
t.Errorf("blev til %q", got)
|
||||
}
|
||||
|
||||
// Og en adresse indad afvises, hvor den skrives — ikke først når den hentes.
|
||||
if _, err := normaliseCalendarURL("http://127.0.0.1:8080/x.ics"); err == nil {
|
||||
t.Error("en loopback-adresse blev godtaget")
|
||||
}
|
||||
if _, err := normaliseCalendarURL("file:///etc/passwd"); err == nil {
|
||||
t.Error("et skema, der ikke er http, blev godtaget")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
||||
// releasing before the marker is written would leave exactly the gap this is
|
||||
// here to close.
|
||||
unlock := s.lockMailbox(m.ID)
|
||||
unlock := s.lockSync(m.ID)
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock. The row that came in may have been fetched before the
|
||||
@@ -175,6 +175,10 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
@@ -198,18 +202,45 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
Description: msg.Snippet,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
// Host and uid together: a uid is only unique within one mailbox on
|
||||
// one server, and two accounts will hand out the same small numbers.
|
||||
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
|
||||
}
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Already a task. Still count it as read, or last_uid never moves
|
||||
// past it and every sweep from here on stops at the same message.
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// The flag comes off what became a task. This is what actually stops a mail
|
||||
// arriving twice; the marker below is now a second line rather than the only
|
||||
// one. See imap.Unflag for why the record belongs in the mailbox and not here.
|
||||
//
|
||||
// Outside the request's context, like the marker: a cancelled request that
|
||||
// already created tasks must still take the flags off them, or the next run
|
||||
// makes them all again.
|
||||
if err := client.Unflag(done); err != nil {
|
||||
// Not fatal, and not the caller's problem. A folder somebody opened
|
||||
// read-only, or a server that refuses the STORE, still gets the uid marker
|
||||
// underneath — worse, but not broken.
|
||||
s.log.Warn("could not unflag imported mail", "err", err, "mailbox", m.ID, "count", len(done))
|
||||
}
|
||||
|
||||
// A run that got all the way through moves the marker to the whole of what the
|
||||
// search considered, not merely to the last message it happened to read. That
|
||||
// is what stops the same mail becoming a task again on the next run when the
|
||||
@@ -231,8 +262,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// lockMailbox serialises one mailbox against itself and returns the release.
|
||||
func (s *Server) lockMailbox(id string) func() {
|
||||
// lockSync serialises one thing that polls against itself and returns the release.
|
||||
//
|
||||
// A mailbox to begin with; a calendar connection now takes the same lock under a
|
||||
// key of its own. Two runs at once — somebody pressing "fetch now" while the sweep
|
||||
// is in the middle of the same thing — both start from the state as it was, and
|
||||
// the loser writes last. For a mailbox that made forty copies of one mail in a
|
||||
// second; for a calendar it would be a window deleted after it was refilled.
|
||||
func (s *Server) lockSync(id string) func() {
|
||||
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,21 +19,38 @@ import (
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
// Access has three ways in, and a note may have any of them at once. A note with
|
||||
// no project is its author's alone — the same answer a task in the inbox gets. A
|
||||
// note in a project is readable by everyone who can read the project and writable
|
||||
// by an editor. And a note can be shared with a person directly, which grants that
|
||||
// person a role of its own. The effective role is the most any of the three allow.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
// mayTouchNote reports whether this person may act on the note at the given role —
|
||||
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
||||
// on a shared project or a shared note, may do the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
// The owner and the project path, exactly as before.
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
if n.CreatedBy == user.ID {
|
||||
return true
|
||||
}
|
||||
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
// A direct share grants its role on top of whatever the above allow.
|
||||
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
|
||||
return shareRole.AtLeast(role)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownsNote reports whether this person is the note's owner — its created_by — which
|
||||
// is who, and only who, may hand it to other people. An editor on a shared note can
|
||||
// change its text; giving away access is the owner's alone, the same shape a
|
||||
// project takes where members edit but only the owner invites.
|
||||
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
|
||||
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -45,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,7 +79,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
// The archive is the same list asked the other way round, so it is the same
|
||||
// endpoint with a flag rather than a second one that would have to be kept in
|
||||
// step with this.
|
||||
if r.URL.Query().Get("archived") != "" {
|
||||
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list archived notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +104,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
}
|
||||
|
||||
// briefly replaces each note's body with the first of it.
|
||||
//
|
||||
// The list shows a title and about ninety characters. It was being sent whole
|
||||
// notes to do that: with twelve hundred of them the response was 845 KB, of which
|
||||
// ninety-eight per cent was body text nobody would read on that screen — fetched,
|
||||
// parsed and held in memory on every visit.
|
||||
//
|
||||
// Body is emptied rather than shortened, and that is the important half. A field
|
||||
// called `body` that holds a *piece* of the body is a trap: the editor would open
|
||||
// it, the debounce would save it, and the note would be cut down to its own
|
||||
// preview by nothing more than being looked at. Empty is a shape the client can
|
||||
// check, and it does — see the notes page, which fetches the whole note when it
|
||||
// opens one.
|
||||
//
|
||||
// Four hundred characters rather than ninety: the client strips Markdown before
|
||||
// it shows anything, and a heading marker and a bold word are characters that
|
||||
// vanish. Cheap insurance against a preview that ends up shorter than the line it
|
||||
// is meant to fill.
|
||||
const previewChars = 400
|
||||
|
||||
func briefly(notes []store.Note) []store.Note {
|
||||
out := make([]store.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
body := []rune(n.Body)
|
||||
if len(body) > previewChars {
|
||||
body = body[:previewChars]
|
||||
}
|
||||
n.Preview = string(body)
|
||||
n.Body = ""
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readable drops what this person may not see.
|
||||
@@ -156,9 +223,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
||||
// Filing a note is the owner's call, not an editor's. A shared editor may
|
||||
// change the text; letting them also move the note would let them file
|
||||
// somebody else's note into a project the owner is not in — and the owner,
|
||||
// who is not a member there, would lose their own note. So the move is
|
||||
// owner-only, and then only into a project the owner may write to.
|
||||
if !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
|
||||
return
|
||||
}
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
@@ -188,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
// Deleting is not editing. Someone the note was shared with — even as an editor
|
||||
// — may change its text, but throwing away the owner's note is the owner's to
|
||||
// do. A project editor keeps the reach they have always had over a project's
|
||||
// notes; a direct share does not extend that far.
|
||||
mayDelete := s.ownsNote(r, n)
|
||||
if !mayDelete && n != nil && n.ProjectID != "" {
|
||||
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
|
||||
mayDelete = perr == nil
|
||||
}
|
||||
if n == nil || !mayDelete {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
@@ -230,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
||||
// the second, so two notes made in the same second have no order between them —
|
||||
// and then which of two same-titled notes gets the plain name and which gets
|
||||
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
||||
// thing by when it was made settles it: the first note of a name keeps the name.
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
||||
return notes[i].ID < notes[j].ID
|
||||
}
|
||||
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
@@ -291,6 +386,14 @@ func noteFilename(title string) string {
|
||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||
const maxNoteImportBytes = 64 << 20
|
||||
|
||||
// What the archive may become once it is unpacked, and how many entries it may
|
||||
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
|
||||
// nothing at all about what it expands to.
|
||||
const (
|
||||
maxNoteImportUnpacked = 2 << 30
|
||||
maxNoteImportFiles = 20_000
|
||||
)
|
||||
|
||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
||||
//
|
||||
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||
@@ -329,30 +432,72 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// note is read its pictures already have addresses to be rewritten to — a note
|
||||
// and its images arrive in whatever order the archive happens to hold them, and
|
||||
// one pass would leave half the links pointing at nothing.
|
||||
// The archive as a whole, not just the upload.
|
||||
//
|
||||
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
|
||||
// MB can become sixty-six gigabytes on the way out — written into the same
|
||||
// volume the database lives on. The upload limit says nothing about that, and
|
||||
// nothing else did either.
|
||||
//
|
||||
// Two ceilings: one per file, and one across the archive. A single enormous
|
||||
// photograph and ten thousand small ones are different attacks with the same
|
||||
// ending.
|
||||
if len(zr.File) > maxNoteImportFiles {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that archive holds too many files")
|
||||
return
|
||||
}
|
||||
budget := int64(maxNoteImportUnpacked)
|
||||
|
||||
stored := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// The header's own claim first, which costs nothing to check and rejects
|
||||
// the obvious case before a byte is written. It is only a claim, so the
|
||||
// reader below is limited as well.
|
||||
if f.UncompressedSize64 > maxUploadBytes {
|
||||
s.log.Warn("import: a file in the archive is too large", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hashed, size, err := s.storeUpload(rc)
|
||||
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
s.log.Warn("import note file", "err", err, "file", name)
|
||||
continue
|
||||
}
|
||||
if size > maxUploadBytes || size > budget {
|
||||
// Over the line once it was actually read: remove what was written
|
||||
// rather than keeping a truncated file that would open as nothing.
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
|
||||
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
budget -= size
|
||||
stored[name] = hashed
|
||||
_ = size
|
||||
files++
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// Counted, not passed over in silence. A Markdown file the filter refuses is
|
||||
// a note that did not arrive, and the caller is told the same number either
|
||||
// way unless it is said here — which is how one note went missing without
|
||||
// anything looking wrong.
|
||||
if skipFile(name) {
|
||||
s.log.Warn("import skipped a note file", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -368,13 +513,24 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: string(body)}
|
||||
text, wrote, changed := readFrontMatter(string(body))
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: text}
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("import note", "err", err, "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// After the save, not as part of it: SaveNote owns `updated_at` and is right
|
||||
// to, so the dates a note had before this program existed are written over
|
||||
// the top rather than passed through it.
|
||||
if !wrote.IsZero() || !changed.IsZero() {
|
||||
if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil {
|
||||
s.log.Warn("import note dates", "err", err, "file", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The pictures the note referred to, now that it has an id to hang them on.
|
||||
// The text is rewritten to the addresses they were given, so an image that
|
||||
// sat in the middle of a paragraph is still in the middle of it.
|
||||
@@ -397,10 +553,34 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
||||
// would double a whole library with each copy full of binary rubbish.
|
||||
func skipFile(name string) bool {
|
||||
return strings.HasPrefix(name, "__MACOSX/") ||
|
||||
if strings.HasPrefix(name, "__MACOSX/") ||
|
||||
strings.HasPrefix(path.Base(name), "._") ||
|
||||
strings.HasPrefix(path.Base(name), ".") ||
|
||||
strings.Contains(name, "..")
|
||||
strings.HasPrefix(path.Base(name), ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Traversal, read as path segments rather than as a substring.
|
||||
//
|
||||
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
|
||||
// wrong thing: it does not describe a path that climbs out of the archive, it
|
||||
// describes any name with two dots next to each other anywhere in it. A note
|
||||
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
|
||||
// silently, and without even counting as skipped, so the number the import
|
||||
// reported still looked right. One note in twelve hundred, found only by
|
||||
// counting what came out.
|
||||
//
|
||||
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
|
||||
// starts at the root, is not — and those are what a zip uses to write outside
|
||||
// the folder it was unpacked into.
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return true
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// attachToNote records the files a note's text points at and rewrites the text to
|
||||
@@ -421,10 +601,20 @@ func (s *Server) attachToNote(
|
||||
continue
|
||||
}
|
||||
base := path.Base(f.Name)
|
||||
// The two ways a Markdown file names a neighbouring image: by the path it
|
||||
// was written with, and by its bare name.
|
||||
// The three ways a Markdown file names a neighbouring image: by its path
|
||||
// inside the archive, by the path relative to the note, and by its bare name.
|
||||
//
|
||||
// The archive path was missing, and it is the one an Apple Notes export
|
||||
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||
// root beside the folder. Only the bare name matched, so only the bare name
|
||||
// was replaced, and every picture in every imported note ended up pointing at
|
||||
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||
// stored and attached correctly; it was the link that was wrong.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
|
||||
refs := []string{f.Name, relative, base}
|
||||
if !strings.Contains(body, f.Name) &&
|
||||
!strings.Contains(body, relative) &&
|
||||
!strings.Contains(body, base) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -441,14 +631,81 @@ func (s *Server) attachToNote(
|
||||
continue
|
||||
}
|
||||
|
||||
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||
// stående foran adressen, og linket peger på ingenting.
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
body = strings.ReplaceAll(body, relative, url)
|
||||
body = strings.ReplaceAll(body, base, url)
|
||||
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||
for _, ref := range refs {
|
||||
if ref != "" {
|
||||
body = strings.ReplaceAll(body, ref, url)
|
||||
}
|
||||
}
|
||||
attached++
|
||||
}
|
||||
return body, attached
|
||||
}
|
||||
|
||||
// readFrontMatter takes the YAML block off the top of a Markdown file and reads
|
||||
// the two dates out of it, returning the rest of the text.
|
||||
//
|
||||
// Not a YAML parser, and deliberately not: the block this reads is the one this
|
||||
// program writes, plus whatever Obsidian and Bear write, and all of them use the
|
||||
// same handful of `key: value` lines. Pulling in a parser to read two dates would
|
||||
// be a dependency that can fail in ways nobody here would recognise.
|
||||
//
|
||||
// A file with no front matter comes back untouched — that is the normal case, and
|
||||
// it must never lose its first line to a rule about a block that is not there.
|
||||
func readFrontMatter(body string) (text string, created, modified time.Time) {
|
||||
if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") {
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
rest := body[strings.Index(body, "\n")+1:]
|
||||
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
// An opening fence with no close is not front matter; it is a horizontal
|
||||
// rule somebody wrote at the top of their note.
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
block := rest[:end]
|
||||
after := rest[end+len("\n---"):]
|
||||
after = strings.TrimPrefix(after, "\r")
|
||||
after = strings.TrimPrefix(after, "\n")
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "created", "created_at", "date":
|
||||
created = parseNoteTime(value)
|
||||
case "modified", "updated", "updated_at":
|
||||
modified = parseNoteTime(value)
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(after, "\n"), created, modified
|
||||
}
|
||||
|
||||
// parseNoteTime accepts the shapes these files actually carry. A date without a
|
||||
// time is that day at midnight, local — the note was written on that day, and
|
||||
// pretending to know the hour would be worse than not having one.
|
||||
func parseNoteTime(v string) time.Time {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
} {
|
||||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// mimeOf guesses from the extension. The archive does not carry a type, and
|
||||
// sniffing the bytes would mean reading every file twice.
|
||||
func mimeOf(name string) string {
|
||||
@@ -457,3 +714,139 @@ func mimeOf(name string) string {
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// handleUploadNoteAttachment takes a file into a note.
|
||||
//
|
||||
// The import could already do this, and nothing else could: a note brought in
|
||||
// from Apple Notes arrived with its pictures, and a note written here could not
|
||||
// be given one. Pasting a screenshot into a note is the most ordinary thing there
|
||||
// is, and it did nothing at all.
|
||||
//
|
||||
// Editor rights on the note, checked the same way every other write to it is. The
|
||||
// answer for "no such note" and "not yours" is the same, so an id cannot be
|
||||
// probed by watching which one comes back.
|
||||
func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxUploadBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
// --- putting notes away, one or many ---------------------------------------------
|
||||
|
||||
type noteBulkRequest struct {
|
||||
IDs []string `json:"ids"`
|
||||
Archived *bool `json:"archived"`
|
||||
Delete bool `json:"delete"`
|
||||
}
|
||||
|
||||
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
|
||||
// and few enough that the loop below cannot become somebody's afternoon.
|
||||
const maxNoteBulk = 500
|
||||
|
||||
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
|
||||
//
|
||||
// One call rather than one per note, because the thing being asked is one thing:
|
||||
// "put these away". Fifty separate requests would be fifty round trips, fifty
|
||||
// chances for half of them to fail, and a list that redraws fifty times — and
|
||||
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
|
||||
// not the extreme one.
|
||||
//
|
||||
// Each note is still checked on its own. A set is a convenience for the caller
|
||||
// and must never become a way to touch something they could not have touched one
|
||||
// at a time.
|
||||
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var req noteBulkRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "required"})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > maxNoteBulk {
|
||||
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
|
||||
return
|
||||
}
|
||||
|
||||
done, skipped := 0, 0
|
||||
for _, id := range req.IDs {
|
||||
n, err := s.db.Note(r.Context(), id)
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// Not an error, and not a 404 for the whole call: a set can contain a note
|
||||
// somebody else deleted a second ago, and the other forty-nine should still
|
||||
// be put away. Counted so the answer is honest about it.
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case req.Delete:
|
||||
err = s.db.DeleteNote(r.Context(), id)
|
||||
case req.Archived != nil:
|
||||
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
|
||||
default:
|
||||
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "note bulk", err)
|
||||
return
|
||||
}
|
||||
done++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
|
||||
//
|
||||
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
|
||||
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
|
||||
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
|
||||
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
|
||||
// tælle efter, hvor mange der kom ud.
|
||||
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
|
||||
keep := []string{
|
||||
"Så blev det endelig jul! Stay tuned... ☕️.md",
|
||||
"Møde 12.8..md",
|
||||
"vedhaeftninger/1-2-Pasted Graphic.png",
|
||||
"noter/Referat....md",
|
||||
}
|
||||
for _, name := range keep {
|
||||
if skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
|
||||
}
|
||||
}
|
||||
|
||||
drop := []string{
|
||||
"../uden for.md",
|
||||
"noter/../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"__MACOSX/._noget",
|
||||
".skjult.md",
|
||||
"noter/._AppleDouble.md",
|
||||
}
|
||||
for _, name := range drop {
|
||||
if !skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// En zip-bombe skal ikke kunne skrive gigabyte ned på den disk, databasen ligger
|
||||
// på.
|
||||
//
|
||||
// Uploaden er begrænset til 64 MB, og det siger ingenting om, hvad de 64 MB
|
||||
// bliver til: deflate når omkring 1032:1, så de kan pakkes ud til seksogtres
|
||||
// gigabyte. Prøven bygger en rigtig lille bombe — fire megabyte nuller, som
|
||||
// pakker til nogle få kilobyte — og ser efter, at loftet på det udpakkede holder.
|
||||
//
|
||||
// Skrevet imod tallene frem for imod endepunktet, fordi det, der skal holde, er
|
||||
// forholdet: en fil, hvis udpakkede størrelse overstiger loftet, må ikke læses
|
||||
// færdig.
|
||||
func TestAZipBombIsRefusedByItsUnpackedSize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("bombe.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nuller pakker næsten helt væk. Fire megabyte er nok til at vise forholdet
|
||||
// uden at prøven selv bruger en gigabyte.
|
||||
if _, err := w.Write(make([]byte, 4<<20)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
packed := int64(buf.Len())
|
||||
if packed > 64<<10 {
|
||||
t.Fatalf("prøvens egen bombe pakkede kun til %d bytes; den viser ikke noget", packed)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), packed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := zr.File[0]
|
||||
if f.UncompressedSize64 != 4<<20 {
|
||||
t.Fatalf("udpakket størrelse = %d", f.UncompressedSize64)
|
||||
}
|
||||
|
||||
// Det, importen gør: læs højst det, budgettet tillader, plus én byte — så en
|
||||
// fil, der er præcis på grænsen, kan skelnes fra en, der er over.
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rc.Close()
|
||||
budget := int64(1 << 20)
|
||||
n, err := io.Copy(io.Discard, io.LimitReader(rc, budget+1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n <= budget {
|
||||
t.Fatalf("læste %d bytes af en fil på 4 MB med et budget på %d — loftet fanger den ikke", n, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// Tallene skal stå i et forhold, der giver mening: en enkelt fil må aldrig kunne
|
||||
// bruge hele arkivets budget, og arkivet må ikke være mindre end én fil.
|
||||
func TestTheImportCeilingsAgreeWithEachOther(t *testing.T) {
|
||||
if maxNoteImportUnpacked <= maxUploadBytes {
|
||||
t.Error("arkivets budget er ikke større end én enkelt fil")
|
||||
}
|
||||
if maxNoteImportFiles < 2000 {
|
||||
t.Error("filloftet er under det, en rigtig Apple Noter-eksport indeholder")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// markShared fills in the two fields the list needs to show a note somebody else
|
||||
// shared with the reader: that it is such a note, and whose it is.
|
||||
//
|
||||
// Done in one pass over the page rather than per note: one query asks which of
|
||||
// these ids are shared with the reader, and one asks for the owners' names and
|
||||
// colours. A note the reader owns is never "shared with me", however it was
|
||||
// reached — the group is other people's notes, not one's own.
|
||||
func (s *Server) markShared(r *http.Request, notes []store.Note) []store.Note {
|
||||
me := userFrom(r.Context()).ID
|
||||
if len(notes) == 0 {
|
||||
return notes
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
if n.CreatedBy != me {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
}
|
||||
shared, err := s.db.NotesSharedWith(r.Context(), me, ids)
|
||||
if err != nil {
|
||||
// The list is worth more than the chip: a note without its "shared" mark
|
||||
// still reads and still opens. Log nothing here — the store already did.
|
||||
return notes
|
||||
}
|
||||
|
||||
owners := map[string]store.Person{}
|
||||
if len(shared) > 0 {
|
||||
ownerIDs := make([]string, 0, len(shared))
|
||||
for _, n := range notes {
|
||||
if shared[n.ID] && n.CreatedBy != "" {
|
||||
ownerIDs = append(ownerIDs, n.CreatedBy)
|
||||
}
|
||||
}
|
||||
owners, _ = s.db.PeopleByIDs(r.Context(), ownerIDs)
|
||||
}
|
||||
|
||||
for i := range notes {
|
||||
if !shared[notes[i].ID] {
|
||||
continue
|
||||
}
|
||||
notes[i].SharedWithMe = true
|
||||
if p, ok := owners[notes[i].CreatedBy]; ok {
|
||||
owner := p
|
||||
notes[i].Owner = &owner
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// handleListNoteShares is who a note is shared with. Owner only: the list of people
|
||||
// who can see your note is itself something only you should see.
|
||||
func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// 404, not 403: telling a stranger "not yours" still confirms the note exists.
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
shares, err := s.db.ListNoteShares(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list note shares", err)
|
||||
return
|
||||
}
|
||||
out := make([]noteShareJSON, 0, len(shares))
|
||||
already := map[string]bool{}
|
||||
for _, sh := range shares {
|
||||
out = append(out, noteShareJSON{
|
||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||
Role: string(sh.Role),
|
||||
})
|
||||
already[sh.User.ID] = true
|
||||
}
|
||||
|
||||
// The picker is filled from the same response, so the panel opens complete in
|
||||
// one round-trip. People already on the note are left out — they are in the list
|
||||
// above it, not the "add someone" menu.
|
||||
candidates, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "share candidates", err)
|
||||
return
|
||||
}
|
||||
cand := make([]personJSON, 0, len(candidates))
|
||||
for _, p := range candidates {
|
||||
if already[p.ID] {
|
||||
continue
|
||||
}
|
||||
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
|
||||
}
|
||||
|
||||
type noteShareJSON struct {
|
||||
User personJSON `json:"user"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type shareNoteRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req shareNoteRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
role := store.Role(req.Role)
|
||||
if role == "" {
|
||||
role = store.RoleViewer
|
||||
}
|
||||
if role != store.RoleViewer && role != store.RoleEditor {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
// The recipient must be a real account and not the sharer themselves; ShareNote
|
||||
// refuses the owner, and a made-up id should not reach it.
|
||||
if req.UserID == userFrom(r.Context()).ID {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||
return
|
||||
}
|
||||
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "share note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUnshareNote takes a person's access away again.
|
||||
func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if err := s.db.UnshareNote(r.Context(), n.ID, chi.URLParam(r, "userID")); err != nil {
|
||||
s.internal(w, r, "unshare note", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createNote makes a note as the current client and returns its id.
|
||||
func (ts *testServer) createNote(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create note: status %d", resp.StatusCode)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create note returned no id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func userID(t *testing.T, ts *testServer, email string) string {
|
||||
t.Helper()
|
||||
u, err := ts.db.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("look up %s: %v", email, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// A note shared with a person shows up in their list, marked as theirs-from-
|
||||
// somebody, and a viewer can read it but not change it.
|
||||
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||
|
||||
// Before sharing, Sofie cannot see it at all.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Share it as a viewer.
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
|
||||
_, list := other.do(t, "GET", "/api/v1/notes", nil)
|
||||
notes, _ := list["notes"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range notes {
|
||||
n := raw.(map[string]any)
|
||||
if n["id"] == noteID {
|
||||
found = n
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the shared note is not in the recipient's list")
|
||||
}
|
||||
if found["shared_with_me"] != true {
|
||||
t.Errorf("the note is not marked shared_with_me: %v", found)
|
||||
}
|
||||
ownerObj, _ := found["owner"].(map[string]any)
|
||||
if ownerObj == nil || ownerObj["name"] != "Kristian" {
|
||||
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
|
||||
}
|
||||
|
||||
// A viewer reads it.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
|
||||
}
|
||||
// But cannot change it.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An editor may change the text, but only the owner may delete the note or hand it
|
||||
// to somebody else.
|
||||
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("share as editor failed")
|
||||
}
|
||||
|
||||
// The editor writes.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The editor cannot re-share it: giving access is the owner's alone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
|
||||
}
|
||||
// The editor cannot delete it.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("an editor deleted the owner's note")
|
||||
}
|
||||
// The note still exists for the owner.
|
||||
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsharing takes access away again, and only the owner can do it.
|
||||
func TestUnshareRemovesAccess(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Hemmelig")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
// A non-owner cannot unshare.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The owner can.
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("owner unshare: %d", resp.StatusCode)
|
||||
}
|
||||
// And access is gone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The share list is owner-only and offers the instance's other people as
|
||||
// candidates, minus those already on the note.
|
||||
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "[email protected]", "Sofie")
|
||||
owner.newUser(t, "[email protected]", "Anders")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Plan")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
shares, _ := out["shares"].([]any)
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("shares = %d, want 1", len(shares))
|
||||
}
|
||||
cands, _ := out["candidates"].([]any)
|
||||
// Anders is a candidate; Sofie is already on the note and so is not.
|
||||
names := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
names[c.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !names["Anders"] {
|
||||
t.Errorf("Anders is not offered as a candidate: %v", names)
|
||||
}
|
||||
if names["Sofie"] {
|
||||
t.Error("somebody already on the note is offered as a candidate")
|
||||
}
|
||||
if names["Kristian"] {
|
||||
t.Error("the owner is offered as a candidate to share with themselves")
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing refuses a made-up person and the owner themselves.
|
||||
func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
me := userID(t, owner, "[email protected]")
|
||||
noteID := owner.createNote(t, "# Note")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("a note was shared at the owner role")
|
||||
}
|
||||
}
|
||||
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
// The list carries a preview, not the whole note — see briefly(). The body is
|
||||
// fetched by id, which is what the interface does when it opens one.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
noteID := notes[0].(map[string]any)["id"].(string)
|
||||
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
|
||||
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
|
||||
}
|
||||
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
body := whole["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/push"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -104,41 +108,186 @@ func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
s.pushToUser(userID, title, body, projectID)
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
// pushOutcome is what happened, per device.
|
||||
//
|
||||
// It exists because "nothing arrives" was unanswerable. A push that a service
|
||||
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
|
||||
// service will not take, a browser that revoked permission without telling the
|
||||
// server — and every one of them looked exactly like silence.
|
||||
type pushOutcome struct {
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
Sent int `json:"sent"`
|
||||
Failed []pushFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// pushFailure names the service and quotes what it said.
|
||||
//
|
||||
// The host and not the endpoint: the rest of that URL is the credential the push
|
||||
// service issued for this device, and it does not belong in an answer, a log line
|
||||
// or a screenshot.
|
||||
type pushFailure struct {
|
||||
Service string `json:"service"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
var out pushOutcome
|
||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return
|
||||
return out
|
||||
}
|
||||
out.Subscriptions = len(subs)
|
||||
|
||||
public, private, err := s.vapidKeys(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("vapid keys", "err", err)
|
||||
return
|
||||
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
|
||||
return out
|
||||
}
|
||||
|
||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||
target := "/projekt/" + projectID
|
||||
if projectID == "" {
|
||||
target = "/"
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||
subject := s.vapidSubject()
|
||||
for _, sub := range subs {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||
}, payload, push.VAPID{
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
Public: public, Private: private, Subject: subject,
|
||||
})
|
||||
if err == nil {
|
||||
out.Sent++
|
||||
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||
s.log.Warn("mark push delivered", "err", delErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
|
||||
// Warn, not Debug.
|
||||
//
|
||||
// This was the one line that would have said why no notification ever
|
||||
// arrived, and at Debug it is not printed on an instance running at the
|
||||
// default level. A delivery that fails silently and a delivery that does
|
||||
// not happen look the same from the outside, and the difference is the
|
||||
// whole diagnosis.
|
||||
reason := pushReason(sub.Endpoint, err)
|
||||
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
|
||||
out.Failed = append(out.Failed, pushFailure{
|
||||
Service: pushHost(sub.Endpoint),
|
||||
Reason: reason,
|
||||
})
|
||||
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
||||
s.log.Warn("record push failure", "err", recErr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushReason is what went wrong, with the endpoint taken back out of it.
|
||||
//
|
||||
// Go wraps a transport failure as `*url.Error`, and its message is `Post
|
||||
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
|
||||
// endpoint, which is the credential the push service issued for that device. It
|
||||
// would have gone into the answer, the log line and any screenshot of either. A
|
||||
// test written to prove the host was reported instead is what found it.
|
||||
//
|
||||
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
|
||||
// anyway: the next error type to carry it will not come with a warning.
|
||||
func pushReason(endpoint string, err error) string {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||
err = urlErr.Err
|
||||
}
|
||||
reason := err.Error()
|
||||
if endpoint != "" {
|
||||
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// pushHost is the service an endpoint belongs to, and nothing else from it.
|
||||
func pushHost(endpoint string) string {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
return "ukendt"
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
// handlePushTest sends one notification to this account's devices and says what
|
||||
// happened to it.
|
||||
//
|
||||
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
|
||||
// settings page said "on for this device" as soon as a subscription existed, and a
|
||||
// refusal from the push service went into a Debug line nobody sees. There was no
|
||||
// way to tell "the browser never subscribed" from "the service refused it" from
|
||||
// "nothing has come due yet" — three different problems with one appearance.
|
||||
//
|
||||
// It goes through the same path as a real notification rather than a shortcut, so
|
||||
// what it proves is the thing that actually has to work.
|
||||
//
|
||||
// The words come from the client, which is where the dictionaries are. A server
|
||||
// that carries its own copy of "This is a test" carries it in one language, and
|
||||
// this one deliberately keeps its prose in the interface — see
|
||||
// `internal/i18n`. The text is only ever shown back to the person who asked for
|
||||
// it, on their own devices.
|
||||
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
title = "verdande"
|
||||
}
|
||||
// Bounded: this is written into a notification on somebody's lock screen, and
|
||||
// a megabyte of title is not a notification.
|
||||
if len(title) > 200 {
|
||||
title = title[:200]
|
||||
}
|
||||
body := strings.TrimSpace(req.Body)
|
||||
if len(body) > 500 {
|
||||
body = body[:500]
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
|
||||
}
|
||||
|
||||
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||
}
|
||||
|
||||
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
||||
//
|
||||
// It has to be one the service will actually accept, and Apple is the strict one:
|
||||
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
||||
// what the old fallback, mailto:[email protected], was on an instance with
|
||||
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
||||
// the person waiting for the notification could tell.
|
||||
//
|
||||
// So a real address is used when there is one, and otherwise the instance's own
|
||||
// https address — which is a valid VAPID subject, points at something that exists,
|
||||
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
||||
// the https URL a 201.
|
||||
func (s *Server) vapidSubject() string {
|
||||
from := strings.TrimSpace(s.cfg.SMTP.From)
|
||||
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
||||
return "mailto:" + from
|
||||
}
|
||||
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
||||
return s.cfg.BaseURL
|
||||
}
|
||||
return "mailto:[email protected]"
|
||||
}
|
||||
|
||||
// --- subscription endpoints -----------------------------------------------------------
|
||||
|
||||
type pushSubscriptionRequest struct {
|
||||
@@ -170,6 +319,23 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The endpoint is an address this server will POST to, on a schedule the
|
||||
// caller chooses — set a reminder a minute out and the push job fetches it,
|
||||
// again every minute. Unchecked, that is a way to make the instance reach
|
||||
// anything it can route to, on demand.
|
||||
//
|
||||
// https only: a push service is on the public internet and speaks TLS. Nothing
|
||||
// legitimate needs http here, and allowing it is the difference between a
|
||||
// blind request and a readable one.
|
||||
if !strings.HasPrefix(req.Endpoint, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": "must be an https address"})
|
||||
return
|
||||
}
|
||||
if reason := safedial.CheckURL(req.Endpoint); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": reason})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
||||
UserID: userFrom(r.Context()).ID,
|
||||
Endpoint: req.Endpoint,
|
||||
@@ -189,7 +355,10 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||
// Scoped to the caller. It was not, so anybody who learned somebody else's
|
||||
// endpoint could unsubscribe their device — a small thing to do to somebody
|
||||
// and no reason at all to be able to.
|
||||
if err := s.db.DeletePushSubscription(r.Context(), userFrom(r.Context()).ID, req.Endpoint); err != nil {
|
||||
s.internal(w, r, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
// Stands in for a run: takes the lock, notes how many are inside, and holds it
|
||||
// until told to let go.
|
||||
run := func(done chan<- struct{}) {
|
||||
unlock := srv.lockMailbox("box-1")
|
||||
unlock := srv.lockSync("box-1")
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > most {
|
||||
@@ -136,10 +136,10 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
|
||||
// And two different mailboxes must not wait for each other: one slow host is
|
||||
// not everybody's problem.
|
||||
a := srv.lockMailbox("box-a")
|
||||
a := srv.lockSync("box-a")
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
srv.lockMailbox("box-b")()
|
||||
srv.lockSync("box-b")()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
|
||||
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||
}
|
||||
|
||||
// handleReorderSections sets the order of the sections inside one project.
|
||||
//
|
||||
// Under /projects/{projectID}/ rather than beside the other section routes, which
|
||||
// hang off a section id: the thing being changed here is the project's list, not
|
||||
// any one section in it. That also puts it behind the same project permission
|
||||
// check as everything else in this subtree, instead of resolving a section to its
|
||||
// project one id at a time and hoping they all named the same one.
|
||||
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// The same ceiling as the projects list, and a bound on the transaction rather
|
||||
// than an opinion about how many sections a project may have.
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
|
||||
s.internal(w, r, "reorder sections", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.updated", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// sectionAccess resolves a section to its project and checks the caller may edit
|
||||
// it. Sections have no permissions of their own — they belong to a project, and
|
||||
// that is where access is decided.
|
||||
|
||||
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sections can be dragged into another order, and the order is the project's.
|
||||
//
|
||||
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
|
||||
// order the work happens in — and until this endpoint existed the only way to
|
||||
// change it was to delete a section and make it again, which moves its tasks to
|
||||
// the unsectioned area on the way past.
|
||||
func TestSectionsCanBeReordered(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
|
||||
id := project["id"].(string)
|
||||
|
||||
ids := map[string]string{}
|
||||
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
|
||||
for _, name := range names {
|
||||
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
|
||||
ids[name] = body["id"].(string)
|
||||
}
|
||||
|
||||
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
|
||||
order := make([]string, len(want))
|
||||
for i, name := range want {
|
||||
order[i] = ids[name]
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||
map[string]any{"ids": order})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
|
||||
got, _ := listed["sections"].([]any)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
|
||||
}
|
||||
for i, raw := range got {
|
||||
if name := raw.(map[string]any)["name"]; name != want[i] {
|
||||
t.Errorf("section %d is %v, want %q", i, name, want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// A section belonging to somebody else's project is not moved by naming it
|
||||
// here. The ids in the body are not checked one by one — the WHERE clause is
|
||||
// what refuses them — so this is the assertion that it actually does.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
|
||||
otherID := other["id"].(string)
|
||||
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
|
||||
map[string]any{"name": "Havearbejde"})
|
||||
foreignID := foreign["id"].(string)
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||
map[string]any{"ids": []string{foreignID}})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
|
||||
elsewhere, _ := stillThere["sections"].([]any)
|
||||
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
|
||||
t.Errorf("the other project's section did not survive: %v", elsewhere)
|
||||
}
|
||||
}
|
||||
|
||||
// A project can be created straight into a group.
|
||||
//
|
||||
// `group_id` was declared on the request type, documented on it, and applied by
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||
// every push to an Apple device was refused. With no real address, the instance's
|
||||
// own https URL is used instead — valid, real, and accepted.
|
||||
func TestVAPIDSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
from string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{"a real address is used as-is", "[email protected]", "https://verdande.example.dk", "mailto:[email protected]"},
|
||||
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
|
||||
if got := s.vapidSubject(); got != tc.want {
|
||||
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// realIP rewrites r.RemoteAddr to the client address a trusted proxy reported,
|
||||
// and does nothing at all when the peer is not trusted.
|
||||
//
|
||||
// This replaces chi's middleware.RealIP, which is deprecated precisely because it
|
||||
// believes a forwarded header from anyone: an attacker sets X-Forwarded-For to a
|
||||
// new value on every request and gets a fresh rate-limit bucket each time, and
|
||||
// writes whatever address they like into the audit log. The one control that
|
||||
// exists for when a password is already gone is keyed on this address, so it has
|
||||
// to be an address the caller cannot choose.
|
||||
//
|
||||
// The gate is the peer. A header is read only when the machine that opened the
|
||||
// connection is one of cfg.TrustedProxies — a reverse proxy or tunnel the operator
|
||||
// put there. A connection straight off the internet is trusted for nothing, so its
|
||||
// header is ignored and it is keyed on the address it actually came from. Behind a
|
||||
// proxy that overwrites the client's own header (nginx, Caddy and cloudflared all
|
||||
// do) the value is then the real client and cannot be forged from outside.
|
||||
func realIP(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
trusted := cfg.TrustedProxies
|
||||
header := cfg.RealIPHeader
|
||||
if header == "" {
|
||||
header = "X-Forwarded-For"
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ip := trustedClientIP(r, trusted, header); ip != "" {
|
||||
// Port preserved off the original peer only for shape; downstream
|
||||
// clientIP() splits it away, and the forwarded value carries none.
|
||||
r.RemoteAddr = ip
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// trustedClientIP returns the forwarded client address if the peer may be believed,
|
||||
// or "" to leave RemoteAddr untouched.
|
||||
func trustedClientIP(r *http.Request, trusted []netip.Prefix, header string) string {
|
||||
peer := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(peer); err == nil {
|
||||
peer = host
|
||||
}
|
||||
peerAddr, err := netip.ParseAddr(peer)
|
||||
if err != nil || !isTrusted(peerAddr, trusted) {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := r.Header.Get(header)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// The leftmost entry is the original client. That entry is only as trustworthy
|
||||
// as the proxy that wrote it — but the trusted proxy is exactly the one that
|
||||
// replaces a client-supplied header, so on the deployments this is written for
|
||||
// it is the real client, and on a direct connection we never reach this line.
|
||||
first := raw
|
||||
if i := strings.IndexByte(raw, ','); i >= 0 {
|
||||
first = raw[:i]
|
||||
}
|
||||
first = strings.TrimSpace(first)
|
||||
if _, err := netip.ParseAddr(first); err != nil {
|
||||
return ""
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func isTrusted(addr netip.Addr, trusted []netip.Prefix) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
for _, p := range trusted {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// The address the rate limiter keys on must be one the caller cannot choose. A
|
||||
// forwarded header is believed only from a trusted proxy, and ignored from a
|
||||
// stranger connecting directly — the difference between a control and a suggestion.
|
||||
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seen := ""
|
||||
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen = clientIP(r)
|
||||
}))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// A private peer is a proxy the operator put there: believe it.
|
||||
name: "trusted proxy is believed",
|
||||
remoteAddr: "10.1.2.3:5000",
|
||||
xff: "203.0.113.9",
|
||||
want: "203.0.113.9",
|
||||
},
|
||||
{
|
||||
// A public peer connected straight to us; its header is a guess.
|
||||
name: "public peer header ignored",
|
||||
remoteAddr: "198.51.100.7:5000",
|
||||
xff: "203.0.113.9",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
// No header from a trusted proxy leaves the peer as the answer.
|
||||
name: "trusted peer without header",
|
||||
remoteAddr: "127.0.0.1:5000",
|
||||
xff: "",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
// A spoofed value from a stranger cannot become the key.
|
||||
name: "spoof from outside does not win",
|
||||
remoteAddr: "198.51.100.7:5000",
|
||||
xff: "127.0.0.1",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
|
||||
r.RemoteAddr = tc.remoteAddr
|
||||
if tc.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", tc.xff)
|
||||
}
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if seen != tc.want {
|
||||
t.Errorf("keyed on %q, want %q", seen, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "none" turns the trust off entirely, for an instance exposed directly.
|
||||
func TestRealIPTrustNone(t *testing.T) {
|
||||
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := ""
|
||||
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen = clientIP(r)
|
||||
}))
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if seen != "10.1.2.3" {
|
||||
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,15 @@ type Server struct {
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
loginLimiter *limiter
|
||||
// Sat kun af prøver; se icsClient().
|
||||
icsFetch *http.Client
|
||||
mailLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
// the ordinary case — and a six-digit code is only safe while guesses are
|
||||
// scarce, so the scarcity has to be counted against the thing being attacked.
|
||||
totpLimiter *limiter
|
||||
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
@@ -80,7 +88,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
// Generous on purpose: the caller is the mail server, one address delivering
|
||||
// everything, and a personal instance gets a handful of forwarded mails a
|
||||
// day. Sixty a minute never touches real delivery and still puts a ceiling
|
||||
// on the one public write path that has none.
|
||||
mailLimiter: newLimiter(60, time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
@@ -93,7 +110,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
// Not middleware.RealIP: that believes a forwarded header from anyone, which
|
||||
// lets a caller pick the address the rate limiter and audit log key on. This
|
||||
// one reads the header only from a trusted proxy — see realip.go.
|
||||
r.Use(realIP(cfg))
|
||||
r.Use(requestLogger(log))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
@@ -113,7 +133,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||
// authenticated by the token in the recipient address.
|
||||
r.Post("/inbound/mail", s.handleInboundMail)
|
||||
//
|
||||
// Rate limited for the same reason the beacon is: it is reachable by anybody
|
||||
// who can route to this instance. It is the weaker case of the two — the
|
||||
// beacon writes a row for a stranger, this one writes nothing without a
|
||||
// 256-bit address it has to already know — but an address is shared with mail
|
||||
// servers and printed on a settings page, and once it is known there was no
|
||||
// ceiling at all on what could be made with it.
|
||||
r.Method(http.MethodPost, "/inbound/mail",
|
||||
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
@@ -133,6 +161,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
|
||||
// The calendar comes back on a URI of its own rather than sharing Gmail's.
|
||||
// One callback deciding from stored state which of the two flows had
|
||||
// returned would put the choice of *which token store to write* behind a
|
||||
// lookup — and when that goes wrong it goes wrong by overwriting a working
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
|
||||
@@ -143,6 +177,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// The beacon's receiving end, and the number it produces. Both without a
|
||||
// session, because the caller is a stranger's installation — that is the
|
||||
// whole feature. Rate limited like the login routes: it is the one endpoint
|
||||
// anybody on the internet can write to, and a row per request is a row per
|
||||
// request whether or not the request is honest.
|
||||
//
|
||||
// Refused with a 404 unless this instance has been told it is the collector,
|
||||
// so an ordinary installation is not a spam sink that answers 200 to
|
||||
// anything.
|
||||
r.Method(http.MethodPost, "/beacon",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeaconPing)))
|
||||
r.Get("/beacon/count", s.handleBeaconCount)
|
||||
|
||||
// Reachable without being signed in — these are how you become signed in.
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Get("/setup", s.handleSetupState)
|
||||
@@ -181,11 +228,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Behind requireSession, like the API tokens and for the same
|
||||
// reason: a leaked token must not be able to end the sessions of
|
||||
@@ -195,6 +238,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
|
||||
// Turning the second factor on, and the codes that get you back
|
||||
// in. Sessions only, and setup asks for the password.
|
||||
//
|
||||
// These sat outside this group, which meant the door opened with
|
||||
// a token and closed only with a password: `disable` has always
|
||||
// asked for the password, `setup` asked for nothing. A stolen
|
||||
// token could therefore fetch the secret, load it into its own
|
||||
// authenticator, confirm it, and keep the only copy of the
|
||||
// recovery codes. The owner's next login would stop at a code
|
||||
// they do not have, and they could not turn it off without
|
||||
// logging in first. A theft becomes a tenancy — the same
|
||||
// sentence written next door about passkeys, and the same rule.
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Registering a key, and the list of them. Sessions only, for
|
||||
// the same reason: a leaked token must not be able to add its
|
||||
// own way in — that would turn a theft into a tenancy.
|
||||
@@ -240,6 +300,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/key", s.handlePushKey)
|
||||
r.Post("/subscribe", s.handleSubscribePush)
|
||||
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
||||
// One notification, to this account's own devices, reporting what
|
||||
// each of them did with it. The answer to "der kommer ikke nogen".
|
||||
r.Post("/test", s.handlePushTest)
|
||||
})
|
||||
|
||||
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
||||
@@ -285,9 +348,21 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
// Sharing a note with a person directly — its own access list, beside
|
||||
// the project path. Owner only; see notes_share_handlers.go.
|
||||
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
// A picture pasted or dropped into a note. The import could bring
|
||||
// files in and nothing else could, which made the most ordinary act
|
||||
// in a notes app — paste a screenshot — do nothing at all.
|
||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||
// Mange på én gang. Efter en import af tolv hundrede noter er det at
|
||||
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
|
||||
r.Post("/bulk", s.handleNoteBulk)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
@@ -316,6 +391,25 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
})
|
||||
|
||||
// A calendar read from somewhere else, laid under verdande's own dated
|
||||
// tasks. Read-only, so there is no write here to gate: the events are a
|
||||
// copy of somebody else's calendar, and the only thing a person decides
|
||||
// is which of them to look at.
|
||||
r.Route("/calendar", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetCalendar)
|
||||
r.Put("/calendars", s.handleSetCalendars)
|
||||
r.Delete("/", s.handleDisconnectCalendar)
|
||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||
r.Post("/sync", s.handleCalendarSyncNow)
|
||||
r.Get("/events", s.handleCalendarEvents)
|
||||
|
||||
// Abonnementer: en kalender, man kun har en adresse til. Flere pr.
|
||||
// person, hvor Google er én — det er adressen, der er identiteten,
|
||||
// hvor tokenet er det for OAuth.
|
||||
r.Post("/subscriptions", s.handleSubscribeCalendar)
|
||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
@@ -365,6 +459,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireProject(store.RoleEditor))
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -390,6 +485,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// `:latest` on the way, which is what makes a restart an update.
|
||||
r.Get("/panel", s.handlePanelStatus)
|
||||
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||
// Who this instance reports to, and whether it does. An
|
||||
// administrator's decision, not a person's: there is one
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
@@ -451,6 +551,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -573,6 +674,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Content-Security-Policy", s.csp)
|
||||
// The session lives in a __Host- cookie that only exists over HTTPS, so the
|
||||
// whole security model already assumes TLS. HSTS is what makes that
|
||||
// assumption hold against a network attacker: without it, the very first
|
||||
// navigation can be stripped to http:// and the cookie handed over in the
|
||||
// clear. Gated on the same condition as the cookie's Secure flag, so a
|
||||
// plain-HTTP dev instance is never told to demand a certificate it cannot
|
||||
// present. Two years, subdomains included; no preload directive, because
|
||||
// asking to be hard-coded into browsers is a decision the operator makes
|
||||
// for their domain, not one a self-hosted app makes for them.
|
||||
if s.secureCookies() {
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
}
|
||||
// verdande uses none of the powerful browser features, so the safest policy
|
||||
// is to turn them all off: an injected element cannot ask for the camera,
|
||||
// the microphone or the location if the page itself has already declined
|
||||
// them. Named explicitly rather than with a wildcard because the wildcard
|
||||
// form of this header is not universally understood, and a directive a
|
||||
// browser ignores is not a control.
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
|
||||
"payment=(), usb=(), interest-cohort=()")
|
||||
// Isolate the browsing context: a window this page opened, or one that
|
||||
// opened it, cannot keep a handle across the origin boundary. verdande
|
||||
// never scripts another window, so there is nothing to lose and a whole
|
||||
// class of cross-window attacks to close.
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,23 +16,24 @@ import (
|
||||
)
|
||||
|
||||
type taskJSON struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
SnoozedUntil string `json:"snoozed_until,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
// What the row can say without being opened. Sent with every task rather than
|
||||
// fetched when one is opened, because the point is that you see it in the list.
|
||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
if t.CompletedAt != nil {
|
||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t.SnoozedUntil != nil {
|
||||
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -698,3 +702,52 @@ func parseLimit(raw string, def, max int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type snoozeRequest struct {
|
||||
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
||||
// but when the task wants to be seen — its due date and everything else stay put.
|
||||
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req snoozeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
var until *time.Time
|
||||
if strings.TrimSpace(req.Until) != "" {
|
||||
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
||||
if perr != nil {
|
||||
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
||||
return
|
||||
}
|
||||
until = &parsed
|
||||
}
|
||||
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
||||
s.storeError(w, r, "snooze task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
verb := "task.snoozed"
|
||||
if until == nil {
|
||||
verb = "task.unsnoozed"
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1777,3 +1778,89 @@ func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
t.Errorf("content = %v", moved["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// A word inside a word, on the task side.
|
||||
//
|
||||
// The notes search grew this first — a note holding `keepInventory` could not be
|
||||
// found by typing "keep inventory" — and a task is the same sentence with a
|
||||
// different table behind it. `SpeedAdmin` is one token, so "speed admin" asks for
|
||||
// two words that are nowhere.
|
||||
//
|
||||
// Having it in one of the two searches would be worse than having it in neither:
|
||||
// "it finds it in my notes but not in my tasks" is the next report.
|
||||
func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Opret kodeord til Kulturskolen (SpeedAdmin)",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Køb kaffe"})
|
||||
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{"speed admin", 1},
|
||||
{"admin", 1},
|
||||
{"speedadmin", 1},
|
||||
{"kulturskolen", 1},
|
||||
{"vandhane", 0},
|
||||
} {
|
||||
resp, body := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(tc.query), nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%q: status %d", tc.query, resp.StatusCode)
|
||||
}
|
||||
tasks, _ := body["tasks"].([]any)
|
||||
if len(tasks) != tc.want {
|
||||
t.Errorf("%q found %d tasks, want %d", tc.query, len(tasks), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A snoozed task sinks to the bottom of the list and greys until its time; waking
|
||||
// it puts it back. The list order is what the row's dimming rides on.
|
||||
func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
|
||||
aID, _ := a["id"].(string)
|
||||
|
||||
// Snooze Alfa two days out.
|
||||
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
|
||||
t.Error("the response does not carry snoozed_until")
|
||||
}
|
||||
|
||||
// Alfa now sorts after Bravo, though it was created first.
|
||||
order := func() []string {
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
var out []string
|
||||
for _, raw := range list["tasks"].([]any) {
|
||||
out = append(out, raw.(map[string]any)["content"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
got := order()
|
||||
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
|
||||
t.Errorf("snoozed task did not sink: order = %v", got)
|
||||
}
|
||||
|
||||
// Wake it, and it comes back above Bravo.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("wake: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if s, _ := body["snoozed_until"].(string); s != "" {
|
||||
t.Errorf("waking left snoozed_until set: %q", s)
|
||||
}
|
||||
if got := order(); got[0] != "Alfa" {
|
||||
t.Errorf("woken task did not return: order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,30 @@ import (
|
||||
type totpSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
|
||||
// hand on a device that cannot scan.
|
||||
QR string `json:"qr"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
// The password, the same as disable has always asked for.
|
||||
//
|
||||
// Symmetry is the point. Turning the second factor on is at least as
|
||||
// consequential as turning it off — it decides which device gets to say yes
|
||||
// from now on — and a door that opens more easily than it closes is not a
|
||||
// door. Together with the move behind requireSession, this is what stops a
|
||||
// stolen credential from enrolling its own authenticator and keeping the only
|
||||
// copy of the recovery codes.
|
||||
var req totpDisableRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil {
|
||||
writeFieldErrors(w, map[string]string{"password": "not correct"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"two-factor authentication is already switched on")
|
||||
@@ -37,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "store totp secret", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
||||
qrSVG, err := auth.TOTPQRSVG(uri)
|
||||
if err != nil {
|
||||
s.internal(w, r, "render totp qr", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
|
||||
}
|
||||
|
||||
type totpConfirmResponse struct {
|
||||
|
||||
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
|
||||
|
||||
for _, t := range cal.Tasks {
|
||||
writeTodo(&b, t, cal.Domain)
|
||||
// A dated task also goes out as an event, for clients that ignore VTODO.
|
||||
if t.DueDatetime != nil && !t.Completed {
|
||||
// A dated task also goes out as an event, for the calendar apps that ignore
|
||||
// VTODO — which is all of them: Apple Calendar and Google both drop a
|
||||
// subscribed to-do on the floor and show only events. A task with a clock
|
||||
// time becomes a timed event; one with only a date, an all-day event. Once
|
||||
// this was limited to timed tasks, so a date-only task — most of them —
|
||||
// simply never appeared in the calendar it was subscribed into.
|
||||
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
|
||||
writeEvent(&b, t, cal.Domain)
|
||||
}
|
||||
}
|
||||
@@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) {
|
||||
}
|
||||
|
||||
func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||
start := *t.DueDatetime
|
||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||
// event collapses to an invisible sliver in most calendar grids.
|
||||
minutes := 30
|
||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||
minutes = *t.DurationMin
|
||||
}
|
||||
end := start.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
// A distinct UID suffix: the event and the to-do describe the same task, and
|
||||
// sharing a UID would make a client treat them as two versions of one item.
|
||||
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
||||
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
||||
writeLine(b, "DTSTART:"+utc(start))
|
||||
writeLine(b, "DTEND:"+utc(end))
|
||||
|
||||
if t.DueDatetime != nil {
|
||||
start := *t.DueDatetime
|
||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||
// event collapses to an invisible sliver in most calendar grids.
|
||||
minutes := 30
|
||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||
minutes = *t.DurationMin
|
||||
}
|
||||
writeLine(b, "DTSTART:"+utc(start))
|
||||
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
|
||||
} else {
|
||||
// A date with no time is an all-day event. DTEND is exclusive — a one-day
|
||||
// event ends on the following date — which is what keeps it a single day in
|
||||
// the grid rather than bleeding into the next.
|
||||
day := strings.ReplaceAll(t.DueDate, "-", "")
|
||||
writeLine(b, "DTSTART;VALUE=DATE:"+day)
|
||||
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
|
||||
}
|
||||
|
||||
writeLine(b, "SUMMARY:"+escape(t.Content))
|
||||
if t.Description != "" {
|
||||
writeLine(b, "DESCRIPTION:"+escape(t.Description))
|
||||
@@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||
|
||||
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||
|
||||
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
|
||||
// DTEND. A malformed date is returned as its own compacted form rather than
|
||||
// dropped, so a task never loses its event over a bad string.
|
||||
func nextDay(date string) string {
|
||||
d, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(date, "-", "")
|
||||
}
|
||||
return d.AddDate(0, 0, 1).Format("20060102")
|
||||
}
|
||||
|
||||
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
|
||||
// a value separator in iCalendar, so an unescaped one silently truncates the
|
||||
// summary at that character in every client.
|
||||
@@ -367,3 +392,180 @@ func unescape(s string) string {
|
||||
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// --- reading a whole calendar ------------------------------------------------------
|
||||
|
||||
// Event is one VEVENT, reduced to what a grid needs — the same shape the Google
|
||||
// side already stores, so the two arrive in the database identically.
|
||||
//
|
||||
// Days at both ends, inclusive, because that is the question a grid asks: which
|
||||
// cells does this cover. StartsAt keeps the stamp as the file wrote it, offset and
|
||||
// all: it is what the chip shows, and reparsing it here would ask *this process*
|
||||
// what time zone it is in.
|
||||
type Event struct {
|
||||
UID string
|
||||
Summary string
|
||||
Location string
|
||||
URL string
|
||||
AllDay bool
|
||||
StartsAt string // RFC3339, empty for an all-day event
|
||||
EndsAt string
|
||||
StartDay string // YYYY-MM-DD
|
||||
EndDay string
|
||||
}
|
||||
|
||||
// Subscribed is what an .ics file somebody subscribed to says.
|
||||
//
|
||||
// Its own name rather than `Calendar`: that one is the *writing* side — what
|
||||
// verdande publishes as a feed — and the two are different documents that happen
|
||||
// to share a format. One name for both would be a promise that a round trip
|
||||
// through this package is lossless, which it is not and does not need to be.
|
||||
type Subscribed struct {
|
||||
Name string
|
||||
TimeZone string
|
||||
Events []Event
|
||||
}
|
||||
|
||||
// ParseCalendar reads an .ics file that somebody subscribed to.
|
||||
//
|
||||
// Forgiving in the same way ParseVTODO is, and for a sharper reason: this file was
|
||||
// written by somebody else's software and is fetched on a schedule with nobody
|
||||
// watching. One property this does not understand must not cost the other four
|
||||
// hundred events in the file — so anything unrecognised is skipped, and an event
|
||||
// without the two things a grid cannot do without (a uid and a start) is dropped
|
||||
// rather than stored half-made.
|
||||
//
|
||||
// # What it deliberately does not do
|
||||
//
|
||||
// **Recurrence is not expanded.** RRULE in a subscribed calendar means "and then
|
||||
// every Tuesday until 2031", and expanding it here would mean writing a year of
|
||||
// rows per rule and re-deciding what happens when the file changes. A recurring
|
||||
// event is stored as its first occurrence, which is honest — it is what the file
|
||||
// says on its own terms — and the day it matters, the answer is an expander shared
|
||||
// with `internal/recurrence`, not a second one written here.
|
||||
//
|
||||
// **Time zones are not resolved.** A DTSTART with TZID=Europe/Copenhagen is stored
|
||||
// with the offset the file carries or, when it carries none, as a local stamp. The
|
||||
// alternative is shipping a tz database and deciding what to do when the file
|
||||
// names a zone this build has never heard of. The day that matters, it is one
|
||||
// place.
|
||||
func ParseCalendar(body string) Subscribed {
|
||||
var cal Subscribed
|
||||
var current *Event
|
||||
depth := 0
|
||||
|
||||
for _, line := range unfold(body) {
|
||||
name, params, value := splitLine(line)
|
||||
upper := strings.ToUpper(name)
|
||||
|
||||
switch upper {
|
||||
case "BEGIN":
|
||||
if strings.EqualFold(value, "VEVENT") {
|
||||
current = &Event{}
|
||||
}
|
||||
depth++
|
||||
continue
|
||||
case "END":
|
||||
depth--
|
||||
if strings.EqualFold(value, "VEVENT") && current != nil {
|
||||
if current.UID != "" && current.StartDay != "" {
|
||||
cal.Events = append(cal.Events, *current)
|
||||
}
|
||||
current = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
// Calendar-level properties. X-WR-CALNAME is what every publisher uses
|
||||
// to say what the calendar is called; there is no standard property for
|
||||
// it, which is why a non-standard one is read here.
|
||||
switch upper {
|
||||
case "X-WR-CALNAME":
|
||||
cal.Name = unescape(value)
|
||||
case "X-WR-TIMEZONE":
|
||||
cal.TimeZone = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch upper {
|
||||
case "UID":
|
||||
current.UID = value
|
||||
case "SUMMARY":
|
||||
current.Summary = unescape(value)
|
||||
case "LOCATION":
|
||||
current.Location = unescape(value)
|
||||
case "URL":
|
||||
current.URL = value
|
||||
case "DTSTART":
|
||||
current.AllDay = isDateOnly(params, value)
|
||||
current.StartDay, current.StartsAt = readStamp(params, value)
|
||||
case "DTEND":
|
||||
day, at := readStamp(params, value)
|
||||
current.EndsAt = at
|
||||
// DTEND is exclusive for an all-day event: a one-day event ends on the
|
||||
// next morning. Stored inclusive, or every all-day event would cover one
|
||||
// cell too many — and a public holiday would eat the day after it.
|
||||
if current.AllDay {
|
||||
day = shiftDay(day, -1)
|
||||
}
|
||||
current.EndDay = day
|
||||
}
|
||||
}
|
||||
|
||||
// A missing DTEND means the event ends when it starts. Filled after the loop
|
||||
// rather than inside it, because DTEND may arrive before DTSTART in a file
|
||||
// nobody promised would be in order.
|
||||
for i := range cal.Events {
|
||||
if cal.Events[i].EndDay == "" || cal.Events[i].EndDay < cal.Events[i].StartDay {
|
||||
cal.Events[i].EndDay = cal.Events[i].StartDay
|
||||
}
|
||||
}
|
||||
return cal
|
||||
}
|
||||
|
||||
// isDateOnly reports whether a stamp is a day rather than a moment. VALUE=DATE
|
||||
// says so outright; an eight-character value says it by being one.
|
||||
func isDateOnly(params, value string) bool {
|
||||
if strings.Contains(strings.ToUpper(params), "VALUE=DATE") {
|
||||
return true
|
||||
}
|
||||
return len(value) == 8 && !strings.ContainsAny(value, "T")
|
||||
}
|
||||
|
||||
// readStamp returns the day and, for a timed event, the moment as RFC3339.
|
||||
//
|
||||
// Three shapes exist in the wild: 20260821 (a day), 20260821T140000Z (UTC), and
|
||||
// 20260821T140000 with a TZID parameter (a local time in a named zone). The third
|
||||
// is kept as written, without a zone: resolving it needs a tz database, and a
|
||||
// stamp that says 14:00 is closer to the truth than one this process has moved.
|
||||
func readStamp(params, value string) (day, at string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 8 {
|
||||
return "", ""
|
||||
}
|
||||
day = value[0:4] + "-" + value[4:6] + "-" + value[6:8]
|
||||
if len(value) < 15 || value[8] != 'T' {
|
||||
return day, ""
|
||||
}
|
||||
|
||||
clock := value[9:11] + ":" + value[11:13] + ":" + value[13:15]
|
||||
switch {
|
||||
case strings.HasSuffix(value, "Z"):
|
||||
return day, day + "T" + clock + "Z"
|
||||
default:
|
||||
// A local stamp, with or without a TZID. Written without an offset, which
|
||||
// is what the interface already treats as "the clock the file said".
|
||||
return day, day + "T" + clock
|
||||
}
|
||||
}
|
||||
|
||||
// shiftDay moves a YYYY-MM-DD by whole days.
|
||||
func shiftDay(day string, by int) string {
|
||||
t, err := time.Parse("2006-01-02", day)
|
||||
if err != nil {
|
||||
return day
|
||||
}
|
||||
return t.AddDate(0, 0, by).Format("2006-01-02")
|
||||
}
|
||||
|
||||
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
|
||||
|
||||
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
||||
// or a feed is useless in the calendar most people actually keep.
|
||||
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
|
||||
func TestDatedTasksBecomeEvents(t *testing.T) {
|
||||
out := Render(sample())
|
||||
|
||||
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
||||
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
||||
strings.Count(out, "BEGIN:VEVENT"))
|
||||
// Every dated, unfinished task becomes an event, so it shows in a calendar app
|
||||
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
|
||||
// date and no event.
|
||||
if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 {
|
||||
t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got)
|
||||
}
|
||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
|
||||
t.Error("the event has no start")
|
||||
|
||||
// The timed one keeps its clock and its 45-minute duration.
|
||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||
t.Error("the timed event's start or duration is wrong")
|
||||
}
|
||||
// 45 minutes of stated duration.
|
||||
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||
t.Error("the event's end does not reflect the task's duration")
|
||||
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
|
||||
// they sit on their own day rather than bleeding into the next.
|
||||
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") {
|
||||
t.Error("the date-only event is not a single all-day event")
|
||||
}
|
||||
// A recurring date-only task carries its rule onto the event.
|
||||
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
|
||||
t.Error("the recurring task's event has no RRULE")
|
||||
}
|
||||
// The event must not share the to-do's UID, or clients treat them as one item.
|
||||
if !strings.Contains(out, "UID:[email protected]") {
|
||||
@@ -220,3 +229,101 @@ func TestFilename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En abonnementskalender, som den ser ud fra nogen andens software.
|
||||
//
|
||||
// Alle fire former, der findes i naturen, i én fil: en heldagsbegivenhed, en med
|
||||
// et UTC-stempel, en med TZID og lokal tid, og en over flere dage. Plus en, der
|
||||
// mangler det, et gitter ikke kan undvære, og en foldet linje — begge dele
|
||||
// kommer, og de kommer uden varsel.
|
||||
func TestParseCalendarReadsTheFourShapesThatExist(t *testing.T) {
|
||||
body := "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"X-WR-CALNAME:Helligdage\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART;VALUE=DATE:20260824\r\n" +
|
||||
"DTEND;VALUE=DATE:20260825\r\n" +
|
||||
"SUMMARY:Grundlovsdag\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART:20260825T120000Z\r\n" +
|
||||
"DTEND:20260825T133000Z\r\n" +
|
||||
`SUMMARY:Møde med en meget lang titel\, der er foldet over to lin` + "\r\n" + " jer\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART;TZID=Europe/Copenhagen:20260826T090000\r\n" +
|
||||
"DTEND;TZID=Europe/Copenhagen:20260826T100000\r\n" +
|
||||
"SUMMARY:Tandlæge\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART;VALUE=DATE:20260901\r\n" +
|
||||
"DTEND;VALUE=DATE:20260904\r\n" +
|
||||
"SUMMARY:Ferie\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"SUMMARY:Uden uid og uden start\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
cal := ParseCalendar(body)
|
||||
|
||||
if cal.Name != "Helligdage" {
|
||||
t.Errorf("kalenderens navn = %q", cal.Name)
|
||||
}
|
||||
if len(cal.Events) != 4 {
|
||||
t.Fatalf("%d begivenheder, want 4 — den femte mangler både uid og start", len(cal.Events))
|
||||
}
|
||||
|
||||
// Heldag. DTEND er eksklusiv i filen: en endagsbegivenhed slutter næste morgen.
|
||||
// Gemmes inklusivt, ellers dækker hver eneste heldagsbegivenhed én celle for
|
||||
// meget — og en helligdag æder dagen efter sig.
|
||||
one := cal.Events[0]
|
||||
if !one.AllDay || one.StartDay != "2026-08-24" || one.EndDay != "2026-08-24" {
|
||||
t.Errorf("heldag: %+v", one)
|
||||
}
|
||||
if one.StartsAt != "" {
|
||||
t.Errorf("en heldagsbegivenhed fik et klokkeslæt: %q", one.StartsAt)
|
||||
}
|
||||
|
||||
// UTC, og den foldede linje samlet igen.
|
||||
two := cal.Events[1]
|
||||
if two.StartsAt != "2026-08-25T12:00:00Z" || two.EndsAt != "2026-08-25T13:30:00Z" {
|
||||
t.Errorf("utc: %+v", two)
|
||||
}
|
||||
if two.Summary != "Møde med en meget lang titel, der er foldet over to linjer" {
|
||||
t.Errorf("den foldede linje blev ikke samlet: %q", two.Summary)
|
||||
}
|
||||
|
||||
// Lokal tid med TZID: klokkeslættet står, som filen skrev det. At flytte det
|
||||
// her ville spørge *denne* proces, hvilken tidszone den er i.
|
||||
three := cal.Events[2]
|
||||
if three.StartsAt != "2026-08-26T09:00:00" {
|
||||
t.Errorf("lokal: %q", three.StartsAt)
|
||||
}
|
||||
|
||||
// Flere dage, begge ender inklusive.
|
||||
four := cal.Events[3]
|
||||
if four.StartDay != "2026-09-01" || four.EndDay != "2026-09-03" {
|
||||
t.Errorf("ferie: %s til %s", four.StartDay, four.EndDay)
|
||||
}
|
||||
}
|
||||
|
||||
// En fil, der ikke er en kalender, må ikke koste noget. Den hentes på et skema
|
||||
// uden nogen, der kigger med, og en fejl her ville være en sweep, der stopper.
|
||||
func TestParseCalendarSurvivesRubbish(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"",
|
||||
"ikke en kalender overhovedet",
|
||||
"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
|
||||
"BEGIN:VEVENT\r\nDTSTART:x\r\nEND:VEVENT\r\n",
|
||||
"BEGIN:VEVENT\r\nUID:a\r\nDTSTART:2026\r\nEND:VEVENT\r\n",
|
||||
} {
|
||||
if got := ParseCalendar(body); len(got.Events) != 0 {
|
||||
t.Errorf("%q gav %d begivenheder", body, len(got.Events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,3 +310,61 @@ func cut(text string) string {
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Unflag takes the flag off the messages that have become tasks.
|
||||
//
|
||||
// This is what stops the same mail arriving twice, and it replaces a marker that
|
||||
// could not be made to hold. The marker was a uid: remember the highest one seen,
|
||||
// start above it next time. That is correct only if uids are what the server says
|
||||
// they are, and it broke in every way such a scheme can — a fetch reply without a
|
||||
// uid, a UIDVALIDITY that changed under a rebuilt mailbox, two mailbox rows
|
||||
// pointing at the same folder and each keeping its own marker. Every one of those
|
||||
// produced the same symptom: the same mail became a task again, and again.
|
||||
//
|
||||
// Unflagging moves the record out of our database and into the mailbox, where the
|
||||
// question is asked. "Flagged" means "turn this into a task"; once it is a task,
|
||||
// the flag has done its job and is not true any more. Nothing has to be
|
||||
// remembered, so there is nothing to get out of step — a second sync, a second
|
||||
// mailbox row, or a database restored from a backup all find the same answer,
|
||||
// because the answer is in the mailbox.
|
||||
//
|
||||
// It is destructive in the small way that matters: it changes something in
|
||||
// somebody's mail. That is the trade, and it is the right way round — a flag is a
|
||||
// note to self that this needs doing, and it has been done.
|
||||
//
|
||||
// The folder is re-selected writable. Since() opens it read-only on purpose, and
|
||||
// a search should not need write access just because a later step might.
|
||||
func (c *Client) Unflag(uids []uint32) error {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := c.c.Select(c.folder, nil).Wait(); err != nil {
|
||||
return fmt.Errorf("open %s for writing: %w", c.folder, err)
|
||||
}
|
||||
|
||||
set := imap.UIDSetNum()
|
||||
for _, u := range uids {
|
||||
if u == 0 {
|
||||
// No uid means nothing to address. The message keeps its flag and will
|
||||
// be seen again — which is the failure this whole function exists to
|
||||
// prevent, so it is worth saying rather than skipping quietly.
|
||||
continue
|
||||
}
|
||||
set.AddNum(imap.UID(u))
|
||||
}
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Silent: the server has nothing to tell us that we would act on, and the
|
||||
// untagged replies would only have to be collected and thrown away.
|
||||
cmd := c.c.Store(set, &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsDel,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagFlagged},
|
||||
}, nil)
|
||||
if err := cmd.Close(); err != nil {
|
||||
return fmt.Errorf("unflag in %s: %w", c.folder, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -316,3 +316,81 @@ func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Den samme mail må ikke kunne blive en opgave to gange.
|
||||
//
|
||||
// Prøven kører hele vejen imod en rigtig server: den finder de flagede mails,
|
||||
// tager flaget af dem, og spørger så forfra som en ny synkronisering ville — med
|
||||
// markøren sat til nul, altså det værst tænkelige tilfælde, hvor alt, hvad vi
|
||||
// selv havde husket, er væk. Der skal ikke komme noget tilbage.
|
||||
//
|
||||
// Det er det, markøren ikke kunne love. Den holdt kun, hvis uids var, hvad
|
||||
// serveren sagde, og fejlede på et fetch-svar uden uid, på en UIDVALIDITY, der
|
||||
// skiftede, og på to postkasse-rækker, der pegede samme sted og hver især førte
|
||||
// deres eget regnskab. Alle tre gav det samme: mailen blev en opgave igen.
|
||||
func TestAnUnflaggedMailDoesNotComeBack(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura 4711", from: "[email protected]", body: "Vedhæftet.", flagged: true},
|
||||
{subject: "Nyhedsbrev", from: "[email protected]", body: "Læs mere", flagged: false},
|
||||
{subject: "Kontrakt", from: "[email protected]", body: "Til underskrift", flagged: true},
|
||||
})
|
||||
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("fandt %d flagede mails, ventede 2", len(msgs))
|
||||
}
|
||||
|
||||
uids := make([]uint32, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
uids = append(uids, m.UID)
|
||||
}
|
||||
if err := client.Unflag(uids); err != nil {
|
||||
t.Fatalf("Unflag: %v", err)
|
||||
}
|
||||
|
||||
// Forfra, og fra nul: selv en instans, der intet husker, må ikke finde dem igen.
|
||||
again, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("%d mails kom igen efter at flaget var taget af — den første af dem er %q",
|
||||
len(again), again[0].Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tomt kald må ikke åbne mappen skrivbar for ingenting.
|
||||
func TestUnflagWithNothingToDoIsQuiet(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura", from: "[email protected]", body: "x", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Unflag(nil); err != nil {
|
||||
t.Errorf("Unflag(nil) = %v", err)
|
||||
}
|
||||
// Kun nuller: der er intet at adressere, og mailen skal beholde sit flag.
|
||||
if err := client.Unflag([]uint32{0, 0}); err != nil {
|
||||
t.Errorf("Unflag(nuller) = %v", err)
|
||||
}
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Errorf("mailen mistede sit flag, uden at nogen havde adresseret den")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,19 @@ type Runner struct {
|
||||
// reason as SyncGmail: it owns turning a message into a task.
|
||||
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
||||
|
||||
// SyncCalendars refreshes one person's connected calendars. Supplied the same
|
||||
// way and for the same reason: the HTTP layer owns the token refresh.
|
||||
SyncCalendars func(ctx context.Context, user *store.User) (int, error)
|
||||
|
||||
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
// SendBeacon reports this installation to the collector, if it is switched on
|
||||
// and a day has passed. Supplied by the HTTP layer for the same reason as the
|
||||
// syncs: that is where the instance settings and the version live.
|
||||
SendBeacon func(ctx context.Context) error
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -69,6 +78,17 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||
// starred and slow enough not to look like abuse.
|
||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||
// Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here
|
||||
// becomes a task, so being a quarter of an hour behind on a meeting next
|
||||
// Tuesday costs nothing — and the "fetch now" button covers the one case where
|
||||
// it matters, which is somebody who has just moved something and is looking at
|
||||
// the grid to check. A person who has just connected does not wait for this
|
||||
// either; connecting fetches the list, and the first sync is one click away.
|
||||
r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars)
|
||||
// Checked hourly, sent at most daily — the same shape as the backup, and for
|
||||
// the same reason: a container asleep at the scheduled minute would otherwise
|
||||
// skip the day. SendBeacon decides whether a day has passed.
|
||||
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -103,6 +123,17 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
|
||||
}()
|
||||
}
|
||||
|
||||
// sendBeacon reports that this installation exists, at most once a day.
|
||||
//
|
||||
// Every decision about what is sent, and why this is on by default rather than
|
||||
// compulsory, lives in internal/beacon. This is only the clock.
|
||||
func (r *Runner) sendBeacon(ctx context.Context) error {
|
||||
if r.SendBeacon == nil {
|
||||
return nil
|
||||
}
|
||||
return r.SendBeacon(ctx)
|
||||
}
|
||||
|
||||
// --- reminders ------------------------------------------------------------------
|
||||
|
||||
// sendDueReminders delivers everything that has come due since the last pass.
|
||||
@@ -322,6 +353,44 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- calendars ------------------------------------------------------------------------
|
||||
|
||||
// syncCalendars refreshes everybody's connected calendars.
|
||||
//
|
||||
// Its own sweep rather than a branch inside the mailbox one. They share nothing
|
||||
// past the word "poll": that loop asks each mailbox how far it has read and makes
|
||||
// tasks, this one replaces a window of somebody else's events. Folding them
|
||||
// together would mean one interval for two questions that do not want the same
|
||||
// answer, and a Google that is down would count against a mailbox that is not.
|
||||
//
|
||||
// Nobody is waiting, so no budget of its own — the request handler has one because
|
||||
// a person is watching a spinner. One account that has gone quiet costs this run
|
||||
// its own timeout and nothing else: the loop moves on per person.
|
||||
func (r *Runner) syncCalendars(ctx context.Context) error {
|
||||
if r.SyncCalendars == nil {
|
||||
return nil
|
||||
}
|
||||
userIDs, err := r.db.UsersWithCalendars(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
user, err := r.db.UserByID(ctx, id)
|
||||
if err != nil || user == nil {
|
||||
continue
|
||||
}
|
||||
written, err := r.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
r.log.Warn("calendar sync", "err", err, "user", id)
|
||||
continue
|
||||
}
|
||||
if written > 0 {
|
||||
r.log.Info("calendar refreshed", "user", id, "events", written)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- housekeeping -----------------------------------------------------------------
|
||||
|
||||
func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||
|
||||
@@ -32,6 +32,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
)
|
||||
|
||||
type Subscription struct {
|
||||
@@ -101,7 +103,19 @@ func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) e
|
||||
req.Header.Set("Urgency", "normal")
|
||||
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
// safedial, not a plain client. The endpoint comes from the browser's
|
||||
// subscription, which is to say from whoever is signed in, and this server
|
||||
// POSTs to it on a schedule they choose — set a reminder a minute out and the
|
||||
// job goes again every minute.
|
||||
//
|
||||
// The subscribe handler already refuses a private address, and that check is
|
||||
// not enough on its own: it runs on the URL as written, and a name it approves
|
||||
// can answer 127.0.0.1 the next time it is looked up. safedial checks the
|
||||
// *resolved* address at connect time, which is the only moment the address
|
||||
// that will actually be used is known — and it checks every redirect hop the
|
||||
// same way. The parse-time check stays, because it says no while somebody is
|
||||
// looking at the screen.
|
||||
resp, err := safedial.Client(15 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A subscription must not be able to point the server at its own network.
|
||||
//
|
||||
// The endpoint is whatever the browser handed over, which is to say whatever the
|
||||
// person signed in handed over, and this server POSTs to it on a schedule they
|
||||
// choose. The subscribe handler refuses a private address when it is written
|
||||
// down — and that check runs on the URL as written, so a name it approves can
|
||||
// answer 127.0.0.1 the next time it is looked up. This is the other half: the
|
||||
// address that will actually be dialled, checked at the moment of dialling.
|
||||
func TestSendRefusesToReachInside(t *testing.T) {
|
||||
reached := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
// httptest listens on 127.0.0.1, which is exactly the destination in question.
|
||||
if !strings.Contains(srv.URL, "127.0.0.1") {
|
||||
t.Skipf("the test server is not on loopback: %s", srv.URL)
|
||||
}
|
||||
|
||||
err := Send(context.Background(), testSubscription(t, srv.URL), Payload{Title: "hej"}, testVAPID(t))
|
||||
if err == nil {
|
||||
t.Fatal("Send reached a loopback address without complaining")
|
||||
}
|
||||
if reached {
|
||||
t.Error("the request arrived: the wall let it through and only the answer failed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSubscription(t *testing.T, endpoint string) Subscription {
|
||||
t.Helper()
|
||||
key, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Subscription{
|
||||
Endpoint: endpoint,
|
||||
P256dh: b64.EncodeToString(key.PublicKey().Bytes()),
|
||||
Auth: b64.EncodeToString(auth),
|
||||
}
|
||||
}
|
||||
|
||||
func testVAPID(t *testing.T) VAPID {
|
||||
t.Helper()
|
||||
pub, priv, err := GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return VAPID{Public: pub, Private: priv, Subject: "mailto:[email protected]"}
|
||||
}
|
||||
@@ -650,8 +650,18 @@ func pad2(n int) string {
|
||||
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
|
||||
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
|
||||
// all of it and leave the task not repeating at all.
|
||||
//
|
||||
// Mellemleddet må være et ord og ikke kun et tal. "hver anden tirsdag" er den måde,
|
||||
// folk siger det på — recurrence-pakken har oversat "hver anden" til "hver 2."
|
||||
// siden den blev skrevet — men her stoppede mønsteret efter "anden" og gav den
|
||||
// sætning videre. Den er ikke en regel, så "hver anden" blev stående i titlen og
|
||||
// "tirsdag" blev en forfaldsdato. Sløjfen nedenfor kan kun *fjerne* ord fra
|
||||
// enden; den kan ikke hente et, mønsteret aldrig tog med.
|
||||
//
|
||||
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||
// ikke var det.
|
||||
var reRepeat = regexp.MustCompile(
|
||||
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
||||
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+|(?:anden|andet|tredje|fjerde|femte|sjette|other)\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
||||
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
|
||||
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
|
||||
|
||||
|
||||
@@ -603,3 +603,36 @@ func TestASectionIsWrittenWithASlash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "hver anden tirsdag" er den måde, folk siger det på.
|
||||
//
|
||||
// recurrence-pakken har oversat "hver anden" til "hver 2." siden den blev skrevet,
|
||||
// men sætningen nåede aldrig frem til den: mønsteret her tog kun ét ord efter
|
||||
// "hver", så det, der blev givet videre, var "hver anden" — ikke en regel. Så blev
|
||||
// de to ord stående i titlen, og "tirsdag" blev til en forfaldsdato. Opgaven
|
||||
// gentog sig ikke, og der stod ikke noget om det.
|
||||
//
|
||||
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||
// ikke var det.
|
||||
func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
rule string
|
||||
left string
|
||||
}{
|
||||
{"vande planterne hver anden tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||
{"vande planterne hver 2. tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||
{"water the plants every other tuesday", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "water the plants"},
|
||||
{"gå tur hver tredje søndag", "FREQ=WEEKLY;BYDAY=SU;INTERVAL=3", "gå tur"},
|
||||
// Den, der virkede i forvejen, skal blive ved med at gøre det.
|
||||
{"tømme opvasker hver mandag", "FREQ=WEEKLY;BYDAY=MO", "tømme opvasker"},
|
||||
} {
|
||||
got := Parse(tc.text, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), "da")
|
||||
if got.Recurrence != tc.rule {
|
||||
t.Errorf("%q gav reglen %q, want %q", tc.text, got.Recurrence, tc.rule)
|
||||
}
|
||||
if got.Content != tc.left {
|
||||
t.Errorf("%q efterlod titlen %q, want %q", tc.text, got.Content, tc.left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package safedial refuses to open a connection to this machine or to the
|
||||
// network it sits on.
|
||||
//
|
||||
// The problem it solves is the same one three times: somewhere a user supplies a
|
||||
// URL or a host, and the server fetches it. An AI provider's base address, a push
|
||||
// endpoint, an IMAP host, a beacon collector. Each of them is a feature, and each
|
||||
// of them is also a way to ask the server to reach something the caller cannot —
|
||||
// the panel next door, a metadata endpoint, a database on the same bridge — and
|
||||
// to report back whether it answered. On a homelab that is the whole point of the
|
||||
// box being where it is.
|
||||
//
|
||||
// # Why the check is on the address and not on the URL
|
||||
//
|
||||
// Parsing the URL and refusing "localhost" or "127.0.0.1" is the obvious version
|
||||
// and it does not work: a name resolves to whatever its owner says, so
|
||||
// `evil.example.com` can answer 127.0.0.1 — and can answer differently the second
|
||||
// time, after a parse-time check has passed. That is DNS rebinding, and the only
|
||||
// place it can be caught is after resolution and before the connection, which is
|
||||
// exactly where net.Dialer's Control hook runs.
|
||||
//
|
||||
// # What is refused
|
||||
//
|
||||
// Loopback, link-local (including 169.254.169.254, which is where cloud metadata
|
||||
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
||||
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
||||
// wall around the inside.
|
||||
//
|
||||
// With one named exception, for Tailscale — see tailnets below.
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tailnets are Tailscale's own ranges, and they are not "the inside".
|
||||
//
|
||||
// A tailnet is not a network you happen to be on; it is one every member had to
|
||||
// authenticate to join. A verdande whose host is on one reaches its peers on
|
||||
// purpose, and the operator who joined it meant to.
|
||||
//
|
||||
// Half of this already worked, by luck rather than intent: Go's IsPrivate covers
|
||||
// RFC1918 and not the carrier-grade NAT range Tailscale borrows, so 100.x
|
||||
// addresses passed the wall without anybody deciding they should. The IPv6 half
|
||||
// did not — fd7a:115c:a1e0::/48 sits inside fc00::/7 and was refused. The same
|
||||
// machine was reachable or not depending on which family its address had, which
|
||||
// is the kind of difference nobody debugs on purpose.
|
||||
//
|
||||
// Naming both makes the allowance deliberate, and writes down its cost: on an
|
||||
// instance with people you have invited, this is a network they can now ask the
|
||||
// server to reach. That is the trade, and it is the operator's to make.
|
||||
var tailnets = []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("fd7a:115c:a1e0::/48"),
|
||||
}
|
||||
|
||||
// IsTailscale reports whether an address belongs to a tailnet.
|
||||
func IsTailscale(ip net.IP) bool {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Unmap first: ParseIP gives a v4 address as 4-in-6, and a v4 prefix does not
|
||||
// contain a v6-shaped address however equal the two look when printed.
|
||||
addr = addr.Unmap()
|
||||
for _, n := range tailnets {
|
||||
if n.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
||||
// deliberately does not say which address it resolved to: the answer is itself
|
||||
// the thing an attacker is fishing for.
|
||||
type ErrPrivate struct{ Host string }
|
||||
|
||||
func (e *ErrPrivate) Error() string {
|
||||
return fmt.Sprintf("refusing to connect to %s: it resolves to an address on this machine or its private network", e.Host)
|
||||
}
|
||||
|
||||
// Blocked reports whether an address must not be dialled.
|
||||
func Blocked(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// Before the private check, not after: a tailnet's IPv6 range is unique-local,
|
||||
// so asking IsPrivate first would refuse it.
|
||||
if IsTailscale(ip) {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsPrivate()
|
||||
}
|
||||
|
||||
// Control is a net.Dialer Control function that refuses a private destination.
|
||||
//
|
||||
// It runs after the name has been resolved and before the socket is connected,
|
||||
// which is the only moment where the address that will actually be used is known.
|
||||
func Control(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if Blocked(ip) {
|
||||
return &ErrPrivate{Host: host}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dialer is a net.Dialer that will not reach inside.
|
||||
func Dialer(timeout time.Duration) *net.Dialer {
|
||||
return &net.Dialer{Timeout: timeout, Control: Control}
|
||||
}
|
||||
|
||||
// Client is an http.Client for fetching something a user named.
|
||||
//
|
||||
// Redirects are followed but capped, and every hop goes through the same dialer —
|
||||
// a redirect to 127.0.0.1 is the oldest way around a check that only looked at the
|
||||
// URL somebody typed.
|
||||
func Client(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: Dialer(timeout).DialContext,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AllowPrivate is the same client without the wall, for a destination the
|
||||
// operator configured rather than a user — the panel, and nothing else. Named so
|
||||
// that using it is a decision somebody wrote down.
|
||||
func AllowPrivate(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
|
||||
// CheckURL reports why an address must not be fetched, or "" if it may be.
|
||||
//
|
||||
// A convenience for saying no at the moment somebody types an address, rather
|
||||
// than later when a request quietly fails. It is NOT the protection — a name can
|
||||
// resolve one way now and another way when it is dialled, which is why Control
|
||||
// exists and runs every time. Anything this rejects is certainly wrong; anything
|
||||
// it accepts is merely not obviously wrong.
|
||||
func CheckURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "that is not a URL"
|
||||
}
|
||||
host := u.Hostname()
|
||||
if ip := net.ParseIP(host); ip != nil && Blocked(ip) {
|
||||
return "that address is on this machine or its private network"
|
||||
}
|
||||
// A bare name is looked up once so an obvious "localhost" is caught here. A
|
||||
// failure to resolve is not an error: the host may simply not exist yet, and
|
||||
// refusing to save an address because DNS was slow would be its own bug.
|
||||
if ips, err := net.LookupIP(host); err == nil {
|
||||
for _, ip := range ips {
|
||||
if Blocked(ip) {
|
||||
return "that address is on this machine or its private network"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBlockedCoversTheInside(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "::1", "0.0.0.0",
|
||||
// Skyens metadata-endepunkt. Den ene adresse, enhver SSRF sigter efter.
|
||||
"169.254.169.254",
|
||||
"10.0.0.5", "172.16.0.1", "192.168.1.10", "fd00::1", "fe80::1",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if !Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"93.184.216.34", "8.8.8.8", "2606:2800:220:1:248:1893:25c8:1946"} {
|
||||
if Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = true, men det er en almindelig adresse", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En httptest-server lytter på loopback, hvilket gør den til den perfekte prøve:
|
||||
// den findes, den svarer, og den må ikke kunne nås.
|
||||
func TestClientRefusesLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("forbindelsen blev oprettet; den skulle være afvist")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Client(3 * time.Second).Get(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("en forespørgsel til loopback skal afvises")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowPrivateStillReaches(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := AllowPrivate(3 * time.Second).Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("den skal kunne nå det, operatøren selv har peget på: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Tailscale skal virke, og på begge familier.
|
||||
//
|
||||
// IPv4-halvdelen slap allerede igennem, men ved et tilfælde: Gos IsPrivate
|
||||
// dækker RFC1918 og ikke det CGNAT-område, Tailscale låner. IPv6-halvdelen gjorde
|
||||
// ikke — fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså
|
||||
// tilgængelig eller ej alt efter, hvilken adressefamilie man ramte den på.
|
||||
//
|
||||
// Testen står her for at holde begge dele fast: at 100.x er tilladt med vilje og
|
||||
// ikke af held, og at fd7a: ikke ryger tilbage bag muren, næste gang nogen rydder
|
||||
// op i prædikatet.
|
||||
func TestTailnetIsNotTheInside(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"100.64.0.0", "100.100.100.100", "100.127.255.255",
|
||||
"fd7a:115c:a1e0::1", "fd7a:115c:a1e0:ab12:3456:7890:abcd:ef01",
|
||||
} {
|
||||
if Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = true, men det er en tailnet-adresse", s)
|
||||
}
|
||||
if !IsTailscale(net.ParseIP(s)) {
|
||||
t.Errorf("IsTailscale(%s) = false", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Undtagelsen skal være så smal, som den er navngivet. Naboerne til området
|
||||
// er ikke Tailscale, og en anden unique-local-adresse er stadig indenfor.
|
||||
for _, s := range []string{
|
||||
"100.63.255.255", // lige under 100.64.0.0/10
|
||||
"100.128.0.0", // lige over
|
||||
"fd00::1", // unique-local, men ikke Tailscales
|
||||
"fd7b:115c:a1e0::1",
|
||||
"192.168.1.150",
|
||||
} {
|
||||
if IsTailscale(net.ParseIP(s)) {
|
||||
t.Errorf("IsTailscale(%s) = true, men den ligger uden for området", s)
|
||||
}
|
||||
}
|
||||
// Og de af dem, der er indenfor, skal stadig være det.
|
||||
for _, s := range []string{"fd00::1", "fd7b:115c:a1e0::1", "192.168.1.150"} {
|
||||
if !Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = false — muren er væk for mere end Tailscale", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The collector's side of the beacon: what has reported in, and how many.
|
||||
//
|
||||
// Every method here is about counting installations, never about identifying one.
|
||||
// There is no lookup by anything but the id an installation made up for itself,
|
||||
// and there is nothing to join it against.
|
||||
|
||||
// BeaconStats is the whole of what the collector can say.
|
||||
type BeaconStats struct {
|
||||
Total int `json:"total"`
|
||||
Active7d int `json:"active_7d"`
|
||||
Active30d int `json:"active_30d"`
|
||||
ByVersion map[string]int `json:"by_version"`
|
||||
LastPingAt *time.Time `json:"last_ping_at,omitempty"`
|
||||
}
|
||||
|
||||
// maxBeaconInstalls bounds the table.
|
||||
//
|
||||
// The receiving endpoint is unauthenticated — it has to be, since the caller is a
|
||||
// stranger's installation — and it inserts a row keyed on an id the caller made
|
||||
// up. Anybody can therefore mint ids and grow this table until the volume the
|
||||
// database lives on is full. A ceiling turns that from a disk-fill into a number
|
||||
// that is merely wrong, which is the difference between an outage and an
|
||||
// annoyance. A hundred thousand is far above any plausible real count and far
|
||||
// below anything that matters on disk.
|
||||
const maxBeaconInstalls = 100_000
|
||||
|
||||
// RecordBeacon writes down that an installation is alive, and on which version.
|
||||
//
|
||||
// `first_seen` is kept from the original row on purpose: it is the only thing
|
||||
// that distinguishes an installation that has been running for a year from one
|
||||
// that appeared this morning, and it costs nothing to keep.
|
||||
func (db *DB) RecordBeacon(ctx context.Context, instanceID, version string) error {
|
||||
now := time.Now().Unix()
|
||||
|
||||
// An installation already known keeps reporting whatever the ceiling says: the
|
||||
// cap must never stop counting the real ones, only stop new rows from being
|
||||
// invented without limit.
|
||||
var known int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs WHERE instance_id = ?`, instanceID).Scan(&known); err != nil {
|
||||
return err
|
||||
}
|
||||
if known == 0 {
|
||||
var total int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs`).Scan(&total); err != nil {
|
||||
return err
|
||||
}
|
||||
if total >= maxBeaconInstalls {
|
||||
// Not an error to the caller. Their installation did nothing wrong, and
|
||||
// there is nothing for them to do about it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO beacon_installs (instance_id, version, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
last_seen = excluded.last_seen`,
|
||||
instanceID, version, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// BeaconCount is how many installations have reported within the window.
|
||||
//
|
||||
// A window rather than a running total, and that is not a detail. A total only
|
||||
// ever climbs: every instance anybody ever started for ten minutes stays in it
|
||||
// forever, so the number stops meaning "how many people use this" and starts
|
||||
// meaning "how many people have ever tried it". The question being asked is the
|
||||
// first one.
|
||||
func (db *DB) BeaconCount(ctx context.Context, within time.Duration) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs WHERE last_seen >= ?`,
|
||||
time.Now().Add(-within).Unix()).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// BeaconSummary is everything the settings page shows.
|
||||
func (db *DB) BeaconSummary(ctx context.Context) (BeaconStats, error) {
|
||||
out := BeaconStats{ByVersion: map[string]int{}}
|
||||
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs`).Scan(&out.Total); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
var err error
|
||||
if out.Active7d, err = db.BeaconCount(ctx, 7*24*time.Hour); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Active30d, err = db.BeaconCount(ctx, 30*24*time.Hour); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Versions of the ones still alive, not of everything ever seen: an instance
|
||||
// that stopped reporting two years ago should not still be counted as running
|
||||
// the version it was on when it stopped.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT version, count(*) FROM beacon_installs
|
||||
WHERE last_seen >= ? AND version <> ''
|
||||
GROUP BY version`, time.Now().Add(-30*24*time.Hour).Unix())
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v string
|
||||
var n int
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.ByVersion[v] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
var last *int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT max(last_seen) FROM beacon_installs`).Scan(&last); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if last != nil {
|
||||
t := time.Unix(*last, 0)
|
||||
out.LastPingAt = &t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CalendarAccount is one connected account belonging to one person.
|
||||
type CalendarAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Provider string `json:"provider"`
|
||||
Account string `json:"account,omitempty"`
|
||||
|
||||
// URL er tom for en OAuth-konto og adressen for et abonnement. Den er
|
||||
// identiteten på et abonnement, som tokenet er det for Google: to rækker med
|
||||
// den samme adresse for den samme person ville være den samme kalender hentet
|
||||
// to gange, uden nogen måde at sige hvilken der gælder.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
RefreshToken string `json:"-"` // never leaves the server
|
||||
AccessToken string `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
|
||||
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Calendar is one calendar inside an account, shown or not.
|
||||
type Calendar struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"-"`
|
||||
RemoteID string `json:"remote_id"`
|
||||
Name string `json:"name"`
|
||||
Colour string `json:"colour,omitempty"`
|
||||
TimeZone string `json:"time_zone,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
// Writable says the connected account could write to it. Nothing does — the
|
||||
// scope is read-only — and it is stored so the day something can, the interface
|
||||
// knows which calendars could even be offered.
|
||||
Writable bool `json:"writable"`
|
||||
Shown bool `json:"shown"`
|
||||
SortOrder int `json:"-"`
|
||||
}
|
||||
|
||||
// CalendarEvent is one occurrence, in the shape a grid asks about: days at both
|
||||
// ends, inclusive.
|
||||
type CalendarEvent struct {
|
||||
ID string `json:"id"`
|
||||
CalendarID string `json:"calendar_id"`
|
||||
RemoteID string `json:"-"`
|
||||
Summary string `json:"summary"`
|
||||
StartsAt string `json:"starts_at,omitempty"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
StartDay string `json:"start_day"`
|
||||
EndDay string `json:"end_day"`
|
||||
AllDay bool `json:"all_day"`
|
||||
Location string `json:"location,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// CalendarName and Colour ride along with the event rather than being looked up
|
||||
// per row by the caller. Same reasoning as the assignee names on a task list:
|
||||
// the chip has to be drawn in the calendar's own colour, and a second request
|
||||
// per event to learn it is a request per event.
|
||||
CalendarName string `json:"calendar_name,omitempty"`
|
||||
Colour string `json:"colour,omitempty"`
|
||||
}
|
||||
|
||||
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
||||
access_token, expires_at, last_sync_at, created_at`
|
||||
|
||||
// Med adressen. Skrivningen tilføjer den selv, fordi indsættelsen navngiver
|
||||
// søjlerne; læsningen skal bede om den.
|
||||
const calendarAccountRead = calendarAccountColumns + `, url`
|
||||
|
||||
// CalendarAccountFor returns a person's connection to one provider, or nil.
|
||||
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
||||
// `url = ''`: en OAuth-konto. Uden det ville et abonnement hos den samme
|
||||
// udbyder kunne svare på spørgsmålet "hvad er personen forbundet til med
|
||||
// Google", og et abonnement har hverken token eller konto at svare med.
|
||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountRead+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`, userID, provider)
|
||||
a, err := db.scanCalendarAccount(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// SaveCalendarAccount 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) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error {
|
||||
if a.ID == "" {
|
||||
a.ID = NewID()
|
||||
}
|
||||
if a.Provider == "" {
|
||||
a.Provider = "google"
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
refresh, err := db.sealValue(a.RefreshToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal refresh_token: %w", err)
|
||||
}
|
||||
access, err := db.sealValue(a.AccessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal access_token: %w", err)
|
||||
}
|
||||
|
||||
// The conflict is on the pair, not on the id: a reconnect builds a fresh
|
||||
// struct with no id in it, and matching on the id would insert a second row
|
||||
// that the unique index then refuses — which reads to the person as "connecting
|
||||
// failed" when what happened is that it already had.
|
||||
// `WHERE url = ''` fordi indekset er delvist siden 0026.
|
||||
//
|
||||
// En OAuth-konto er én pr. person og udbyder — det er tokenet, der er
|
||||
// identiteten — mens et abonnement er én pr. adresse, og man har flere. De to
|
||||
// bor i samme tabel, fordi et abonnement *er* en kalender med begivenheder i,
|
||||
// og den halvdel er den samme. Målet for konflikten skal sige, hvilken af de to
|
||||
// slags rækker der tales om, ellers ved SQLite det ikke.
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, provider) WHERE url = '' DO UPDATE SET
|
||||
account = excluded.account,
|
||||
refresh_token = excluded.refresh_token,
|
||||
access_token = excluded.access_token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_sync_at = excluded.last_sync_at`,
|
||||
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix(), a.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.URL != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The row that is actually there wins. On a reconnect the insert above updated
|
||||
// an existing row, so the id in hand is one nothing points at — and the
|
||||
// calendars are hung off the id, so writing them against it would attach them
|
||||
// to nothing.
|
||||
return db.QueryRowContext(ctx,
|
||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`,
|
||||
a.UserID, a.Provider).Scan(&a.ID)
|
||||
}
|
||||
|
||||
// MarkCalendarSynced 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) MarkCalendarSynced(ctx context.Context, accountID string, at time.Time) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE calendar_accounts SET last_sync_at = ? WHERE id = ?`, at.Unix(), accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCalendarAccount disconnects one. The calendars and their events go with it
|
||||
// — unlike the tasks a mailbox made, none of this is the person's own work; it is
|
||||
// a copy of somebody else's calendar and it has no meaning without the account.
|
||||
func (db *DB) DeleteCalendarAccount(ctx context.Context, userID, id string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM calendar_accounts WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsersWithCalendars returns everybody the sweep has to visit.
|
||||
func (db *DB) UsersWithCalendars(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM calendar_accounts`)
|
||||
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()
|
||||
}
|
||||
|
||||
// Calendars returns what one account holds, in the order the provider listed them.
|
||||
func (db *DB) Calendars(ctx context.Context, accountID string) ([]Calendar, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, account_id, remote_id, name, colour, time_zone, primary_one,
|
||||
writable, shown, sort_order
|
||||
FROM calendars WHERE account_id = ? ORDER BY sort_order, name`, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Calendar
|
||||
for rows.Next() {
|
||||
var c Calendar
|
||||
if err := rows.Scan(&c.ID, &c.AccountID, &c.RemoteID, &c.Name, &c.Colour,
|
||||
&c.TimeZone, &c.Primary, &c.Writable, &c.Shown, &c.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceCalendars writes the list the provider just gave, keeping what the person
|
||||
// chose.
|
||||
//
|
||||
// The choice survives because it is not in the list: Google says which calendars
|
||||
// exist and what they are called, and only verdande knows which of them somebody
|
||||
// wants to look at. Merged rather than deleted and rewritten, so ticking a calendar
|
||||
// on does not come undone the next time the sweep runs.
|
||||
//
|
||||
// A calendar that has disappeared from the account is removed, and its events go
|
||||
// with it — a calendar somebody was unshared from should stop being drawn, not
|
||||
// stay behind as a frozen copy of what it held on the last good day.
|
||||
func (db *DB) ReplaceCalendars(ctx context.Context, accountID string, list []Calendar) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
keep := map[string]bool{}
|
||||
for i, c := range list {
|
||||
keep[c.RemoteID] = true
|
||||
// The insert cannot carry `shown`: on conflict it would overwrite the
|
||||
// choice with the default. A new calendar starts hidden — an account
|
||||
// with a dozen shared calendars in it should not fill somebody's grid
|
||||
// the moment they connect it.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendars
|
||||
(id, account_id, remote_id, name, colour, time_zone, primary_one,
|
||||
writable, shown, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (account_id, remote_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
colour = excluded.colour,
|
||||
time_zone = excluded.time_zone,
|
||||
primary_one = excluded.primary_one,
|
||||
writable = excluded.writable,
|
||||
sort_order = excluded.sort_order`,
|
||||
NewID(), accountID, c.RemoteID, c.Name, c.Colour, c.TimeZone,
|
||||
c.Primary, c.Writable, c.Shown, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, remote_id FROM calendars WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var gone []string
|
||||
for rows.Next() {
|
||||
var id, remote string
|
||||
if err := rows.Scan(&id, &remote); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if !keep[remote] {
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
// Closed before the next statement runs on this transaction. An open result
|
||||
// set with a query behind it is the deadlock the pool comment in store.go
|
||||
// exists to make slow rather than fatal.
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range gone {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM calendars WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ShowCalendars sets which of an account's calendars are drawn.
|
||||
//
|
||||
// The whole set in one write rather than one flip at a time, for the reason the
|
||||
// projects' order is written the same way: a person has a handful of calendars, the
|
||||
// question is "these ones", and a list that can only be built by several requests
|
||||
// can land half applied.
|
||||
func (db *DB) ShowCalendars(ctx context.Context, accountID string, shown []string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE calendars SET shown = 0 WHERE account_id = ?`, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range shown {
|
||||
// Scoped in the SQL rather than checked first: an id read in one step
|
||||
// and written in the next is a window, and the account is the whole of
|
||||
// the permission here.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE calendars SET shown = 1 WHERE id = ? AND account_id = ?`,
|
||||
id, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Events of a calendar nobody is looking at are dead weight in a table the
|
||||
// grid queries. They come back with the next sync if it is turned on again.
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM calendar_events WHERE calendar_id IN
|
||||
(SELECT id FROM calendars WHERE account_id = ? AND shown = 0)`, accountID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceCalendarEvents swaps one calendar's events inside a window.
|
||||
//
|
||||
// Replaced rather than merged, and no sync token. Google offers incremental sync,
|
||||
// and it would save bytes verdande is not short of while costing the one thing it
|
||||
// cannot afford: a token that has to be right, forever, for the grid not to be
|
||||
// quietly wrong. A window that is deleted and rewritten cannot drift — an event
|
||||
// somebody moved out of the window disappears because it is not in the answer, not
|
||||
// because verdande remembered to delete it.
|
||||
//
|
||||
// Bounded by the window at both ends, so a sync of September does not throw away
|
||||
// August.
|
||||
func (db *DB) ReplaceCalendarEvents(ctx context.Context, calendarID, from, to string, events []CalendarEvent) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM calendar_events
|
||||
WHERE calendar_id = ? AND end_day >= ? AND start_day <= ?`,
|
||||
calendarID, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range events {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendar_events
|
||||
(id, calendar_id, remote_id, summary, starts_at, ends_at,
|
||||
start_day, end_day, all_day, location, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), calendarID, e.RemoteID, e.Summary, e.StartsAt, e.EndsAt,
|
||||
e.StartDay, e.EndDay, e.AllDay, e.Location, e.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CalendarEvents returns everything one person can see between two days,
|
||||
// inclusive at both ends.
|
||||
//
|
||||
// Scoped to the caller in the join rather than checked afterwards: the events of
|
||||
// somebody else's calendar are somebody else's, and a filter applied after the read
|
||||
// is a filter that can be forgotten.
|
||||
func (db *DB) CalendarEvents(ctx context.Context, userID, from, to string) ([]CalendarEvent, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT e.id, e.calendar_id, e.remote_id, e.summary, e.starts_at, e.ends_at,
|
||||
e.start_day, e.end_day, e.all_day, e.location, e.url, c.name, c.colour
|
||||
FROM calendar_events e
|
||||
JOIN calendars c ON c.id = e.calendar_id
|
||||
JOIN calendar_accounts a ON a.id = c.account_id
|
||||
WHERE a.user_id = ? AND c.shown = 1 AND e.end_day >= ? AND e.start_day <= ?
|
||||
ORDER BY e.start_day, e.all_day DESC, e.starts_at`, userID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []CalendarEvent
|
||||
for rows.Next() {
|
||||
var e CalendarEvent
|
||||
if err := rows.Scan(&e.ID, &e.CalendarID, &e.RemoteID, &e.Summary, &e.StartsAt,
|
||||
&e.EndsAt, &e.StartDay, &e.EndDay, &e.AllDay, &e.Location, &e.URL,
|
||||
&e.CalendarName, &e.Colour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
||||
var a CalendarAccount
|
||||
var expires, lastSync, created int64
|
||||
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
||||
&a.AccessToken, &expires, &lastSync, &created, &a.URL)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
for _, field := range []*string{&a.RefreshToken, &a.AccessToken} {
|
||||
plain, err := db.unsealValue(*field)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
*field = plain
|
||||
}
|
||||
a.ExpiresAt = timeOrZero(expires)
|
||||
a.LastSyncAt = timeOrZero(lastSync)
|
||||
a.CreatedAt = time.Unix(created, 0)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// --- abonnementer ---------------------------------------------------------------
|
||||
|
||||
// SubscribeCalendar adds a calendar somebody only has an address to.
|
||||
//
|
||||
// One row in `calendar_accounts` with a url and no tokens, and one row in
|
||||
// `calendars` hung off it — because a subscription *is* one calendar, where an
|
||||
// OAuth account is a list of them somebody picks from. Written together so a
|
||||
// subscription is never a row with nothing under it: the sweep looks for accounts,
|
||||
// and one without a calendar would be fetched for ever and stored nowhere.
|
||||
//
|
||||
// Shown from the start. Ticking a box after adding an address is a second step for
|
||||
// a decision the person already made by adding it.
|
||||
func (db *DB) SubscribeCalendar(ctx context.Context, userID, url, name string) (*CalendarAccount, error) {
|
||||
account := &CalendarAccount{
|
||||
ID: NewID(),
|
||||
UserID: userID,
|
||||
Provider: "ics",
|
||||
Account: name,
|
||||
URL: url,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendar_accounts (id, user_id, provider, account, url, created_at)
|
||||
VALUES (?, ?, 'ics', ?, ?, ?)`,
|
||||
account.ID, userID, name, url, account.CreatedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendars (id, account_id, remote_id, name, shown)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
NewID(), account.ID, url, name)
|
||||
return err
|
||||
})
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrDuplicate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// Subscriptions lists the calendars a person subscribes to by address.
|
||||
func (db *DB) Subscriptions(ctx context.Context, userID string) ([]CalendarAccount, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND url <> ''
|
||||
ORDER BY created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []CalendarAccount{}
|
||||
for rows.Next() {
|
||||
a, err := db.scanCalendarAccount(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AllSubscriptions is every subscription on the instance, for the sweep.
|
||||
//
|
||||
// The sweep runs for the server rather than for a session, so it cannot ask "whose
|
||||
// is this" the way a handler can — it is handed the user id along with the row and
|
||||
// writes against that.
|
||||
func (db *DB) AllSubscriptions(ctx context.Context) ([]CalendarAccount, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
|
||||
FROM calendar_accounts WHERE url <> '' ORDER BY last_sync_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []CalendarAccount{}
|
||||
for rows.Next() {
|
||||
a, err := db.scanCalendarAccount(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RenameSubscribedCalendar writes the name the file calls itself.
|
||||
//
|
||||
// A subscription is added with the address as its name, because that is all there
|
||||
// is to go on before it has been fetched once. The file usually says better —
|
||||
// X-WR-CALNAME is what every publisher uses — and taking it means the list says
|
||||
// "Danske helligdage" instead of a URL nobody reads.
|
||||
//
|
||||
// Only when the person has not renamed it themselves: a name they typed is an
|
||||
// instruction, and a name from the file is a guess.
|
||||
func (db *DB) RenameSubscribedCalendar(ctx context.Context, accountID, name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil
|
||||
}
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE calendar_accounts SET account = ?
|
||||
WHERE id = ? AND (account = '' OR account = url)`, name, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE calendars SET name = ?
|
||||
WHERE account_id = ? AND (name = '' OR name = remote_id)`, name, accountID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// SubscribedCalendarID is the single calendar row under a subscription.
|
||||
func (db *DB) SubscribedCalendarID(ctx context.Context, accountID string) (string, error) {
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id FROM calendars WHERE account_id = ? LIMIT 1`, accountID).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func connectedAccount(t *testing.T, db *DB, userID string) *CalendarAccount {
|
||||
t.Helper()
|
||||
a := &CalendarAccount{
|
||||
UserID: userID, Provider: "google", Account: "[email protected]",
|
||||
RefreshToken: "1//0-a-real-looking-refresh-token", AccessToken: "ya29.access",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
if err := db.SaveCalendarAccount(context.Background(), a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// The same claim the mailbox tokens have to meet: what is written must not be
|
||||
// readable in the file a backup copies.
|
||||
func TestCalendarTokensAreNotStoredInTheClear(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
var refresh, access string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT refresh_token, access_token FROM calendar_accounts WHERE id = ?`,
|
||||
a.ID).Scan(&refresh, &access); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(refresh, "real-looking") || strings.Contains(access, "ya29") {
|
||||
t.Fatalf("a token is in the row as written: %q %q", refresh, access)
|
||||
}
|
||||
// The address stays readable, which is the reason for sealing named columns
|
||||
// rather than the whole row.
|
||||
var account string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT account FROM calendar_accounts WHERE id = ?`, a.ID).Scan(&account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account != "[email protected]" {
|
||||
t.Errorf("the address was sealed too: %q", account)
|
||||
}
|
||||
|
||||
back, err := db.CalendarAccountFor(ctx, userID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.RefreshToken != a.RefreshToken || back.AccessToken != a.AccessToken {
|
||||
t.Errorf("the tokens did not survive the round trip: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting again must replace what was there, not fail. It is the same OAuth
|
||||
// flow run a second time, and the person's word for it is "connect", not "add".
|
||||
func TestConnectingAgainReplacesTheAccount(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
first := connectedAccount(t, db, userID)
|
||||
|
||||
second := &CalendarAccount{
|
||||
UserID: userID, Provider: "google", Account: "[email protected]",
|
||||
RefreshToken: "1//0-fresh", AccessToken: "ya29.fresh",
|
||||
}
|
||||
if err := db.SaveCalendarAccount(ctx, second); err != nil {
|
||||
t.Fatalf("connecting a second time failed: %v", err)
|
||||
}
|
||||
// And the id in hand is the row that is actually there. The calendars hang off
|
||||
// it, so an id nothing points at would attach them to nothing.
|
||||
if second.ID != first.ID {
|
||||
t.Errorf("the second connect came back with id %q, want the existing %q",
|
||||
second.ID, first.ID)
|
||||
}
|
||||
|
||||
back, err := db.CalendarAccountFor(ctx, userID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.RefreshToken != "1//0-fresh" {
|
||||
t.Errorf("the token came back as %q", back.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Google says which calendars exist; only verdande knows which ones somebody wants
|
||||
// to look at. A refresh that came back with the same list must not undo the choice.
|
||||
func TestRefreshingTheListKeepsWhatWasChosen(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
list := []Calendar{
|
||||
{RemoteID: "primary", Name: "[email protected]", Colour: "#3f51b5", Primary: true},
|
||||
{RemoteID: "fam", Name: "Familien", Colour: "#0b8043"},
|
||||
{RemoteID: "old", Name: "Et delt"},
|
||||
}
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Nothing is shown to begin with: an account with a dozen shared calendars in
|
||||
// it should not fill somebody's grid the moment they connect it.
|
||||
stored, err := db.Calendars(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 3 {
|
||||
t.Fatalf("got %d calendars, want 3", len(stored))
|
||||
}
|
||||
for _, c := range stored {
|
||||
if c.Shown {
|
||||
t.Errorf("%q was shown without anybody asking for it", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var famID string
|
||||
for _, c := range stored {
|
||||
if c.RemoteID == "fam" {
|
||||
famID = c.ID
|
||||
}
|
||||
}
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{famID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The sweep runs again, and one calendar has been renamed and one has gone.
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{
|
||||
{RemoteID: "primary", Name: "[email protected]", Colour: "#3f51b5", Primary: true},
|
||||
{RemoteID: "fam", Name: "Familien og hunden", Colour: "#0b8043"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, err = db.Calendars(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("got %d calendars; one that has left the account should stop being drawn",
|
||||
len(stored))
|
||||
}
|
||||
for _, c := range stored {
|
||||
switch c.RemoteID {
|
||||
case "fam":
|
||||
if !c.Shown {
|
||||
t.Error("the choice was undone by a refresh of the list")
|
||||
}
|
||||
if c.Name != "Familien og hunden" {
|
||||
t.Errorf("the new name did not arrive: %q", c.Name)
|
||||
}
|
||||
if c.ID != famID {
|
||||
t.Error("the row was rebuilt rather than updated; its id is what was chosen")
|
||||
}
|
||||
case "primary":
|
||||
if c.Shown {
|
||||
t.Error("a calendar nobody chose became shown")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A window is replaced, not merged. An event moved out of it has to disappear
|
||||
// because it is absent from the answer — and the months on either side must not go
|
||||
// with it.
|
||||
func TestReplacingAWindowLeavesTheMonthsAroundItAlone(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "primary", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
cal := stored[0]
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{cal.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
july := CalendarEvent{RemoteID: "j", Summary: "Juli", StartDay: "2026-07-15", EndDay: "2026-07-15"}
|
||||
august := CalendarEvent{RemoteID: "a", Summary: "August", StartDay: "2026-08-15", EndDay: "2026-08-15"}
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-07-01", "2026-07-31",
|
||||
[]CalendarEvent{july}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{august}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want both months: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// August is synced again and the meeting has been cancelled. July stays.
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Summary != "Juli" {
|
||||
t.Fatalf("got %+v; syncing August threw away July", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The grid asks "which cells does this cover", so an event that straddles the edge
|
||||
// of the question has to come back.
|
||||
func TestAnEventThatStraddlesTheWindowIsFound(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min", Colour: "#0b8043"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-09-30",
|
||||
[]CalendarEvent{
|
||||
{RemoteID: "trip", Summary: "Aarhus", StartDay: "2026-08-28", EndDay: "2026-09-02", AllDay: true},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A week that contains neither end of it.
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-08-31", "2026-09-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %+v; an event covering the whole week was not found", got)
|
||||
}
|
||||
// The calendar's own colour rides along, or the chip has to ask for it per row.
|
||||
if got[0].Colour != "#0b8043" || got[0].CalendarName != "Min" {
|
||||
t.Errorf("the calendar did not come with the event: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's calendar is somebody else's.
|
||||
func TestOnlyTheOwnersEventsComeBack(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
other := &User{Email: "[email protected]", Name: "Andreas"}
|
||||
if err := db.CreateUser(ctx, other, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs := &CalendarAccount{UserID: other.ID, Provider: "google", RefreshToken: "r"}
|
||||
if err := db.SaveCalendarAccount(ctx, theirs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendars(ctx, theirs.ID, []Calendar{{RemoteID: "c", Name: "Deres"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, theirs.ID)
|
||||
if err := db.ShowCalendars(ctx, theirs.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Privat", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("somebody else's calendar came back: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A calendar turned off stops being drawn, and stops costing the grid anything.
|
||||
func TestTurningACalendarOffRemovesItsEvents(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.ShowCalendars(ctx, a.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM calendar_events`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("%d events of a calendar nobody is looking at are still in the table", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the calendars and the events with it. Unlike the tasks a
|
||||
// mailbox made, none of this is the person's own work — it is a copy of somebody
|
||||
// else's calendar and it means nothing without the account.
|
||||
func TestDisconnectingTakesEverythingWithIt(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.DeleteCalendarAccount(ctx, userID, a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"calendar_accounts", "calendars", "calendar_events"} {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM `+table).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("%s still holds %d rows after disconnecting", table, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sweep finds people by the presence of an account, so it has to keep working
|
||||
// now that the credential in it is ciphertext.
|
||||
func TestTheSweepFindsAConnectedCalendar(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
connectedAccount(t, db, userID)
|
||||
|
||||
users, err := db.UsersWithCalendars(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 1 || users[0] != userID {
|
||||
t.Fatalf("the sweep found %v", users)
|
||||
}
|
||||
}
|
||||
@@ -195,10 +195,14 @@ func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
|
||||
}
|
||||
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
||||
`"email":"[email protected]","seen":["abc","def"],"project_id":"p-1"}`
|
||||
// The conflict target is (user_id, scope) since 0025 — before that the table
|
||||
// held one row per person and the upsert renamed its scope, which is the bug
|
||||
// 0025 exists to undo. Nothing conflicts here; the clause only has to be legal
|
||||
// against the schema this test is running on.
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||
VALUES (?, 'gmail', ?, 0)
|
||||
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
|
||||
ON CONFLICT (user_id, scope) DO UPDATE SET values_json = excluded.values_json`,
|
||||
userID, values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- One row per installation that has ever reported in.
|
||||
--
|
||||
-- This table only exists on the instance that is acting as the collector. Every
|
||||
-- other instance has it too, empty, because the schema is the same everywhere —
|
||||
-- and because "am I the collector" is a setting, not a build.
|
||||
--
|
||||
-- What is deliberately NOT here is the point of the whole feature: there is no
|
||||
-- column for an IP address, a hostname, a domain, an account, a count of tasks,
|
||||
-- or anything else. The two things stored are the two things sent, and the table
|
||||
-- is the promise written down where it cannot drift from the prose — a column
|
||||
-- added here later is a promise broken, and it should be as awkward to add as
|
||||
-- possible.
|
||||
--
|
||||
-- `instance_id` is the primary key rather than a rowid alias, because the same
|
||||
-- installation reporting daily must update its row rather than grow a new one.
|
||||
-- What is wanted is "how many installations", not "how many pings".
|
||||
CREATE TABLE beacon_installs (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The count is always "seen in the last N days", so last_seen is what every query
|
||||
-- sorts and filters on.
|
||||
CREATE INDEX beacon_by_last_seen ON beacon_installs (last_seen DESC);
|
||||
@@ -0,0 +1,111 @@
|
||||
-- Calendars read from somewhere else: the account, the calendars in it, and the
|
||||
-- events they hold.
|
||||
--
|
||||
-- Not in `mailboxes`, and it was the first thing considered. The two look alike
|
||||
-- from a distance — a person connects a Google account over OAuth, tokens are
|
||||
-- sealed, a sweep polls it — and every column past that disagrees. `trigger_kind`,
|
||||
-- `seen`, `last_uid` and `project_id` all answer "what turns a message into a
|
||||
-- task", and nothing here turns into a task. What is here instead is a *set*:
|
||||
-- one account holds many calendars, and a person picks which of them to look at.
|
||||
-- `mailboxes` has no room for a one-to-many, and giving it one would push the
|
||||
-- other kind's columns onto rows that can never use them.
|
||||
--
|
||||
-- The decisive part is smaller and more practical. `mailboxes.kind` carries
|
||||
-- `CHECK (kind IN ('gmail', 'imap'))`, and SQLite cannot alter a CHECK — adding a
|
||||
-- third value means rebuilding the table with foreign keys off, which is the
|
||||
-- expensive, careful thing migration 0021 had to do for a reason. Paying that to
|
||||
-- store something that shares no column with the table is the wrong trade.
|
||||
--
|
||||
-- Hence no CHECK on `provider` here. CalDAV is the next source to read, and it
|
||||
-- should cost a row, not a rebuild.
|
||||
|
||||
-- One connected account per person per provider. The tokens live here rather than
|
||||
-- on each calendar: a refresh token copied onto ten rows is ten copies of one
|
||||
-- secret, ten refreshes racing each other, and ten places to miss when access is
|
||||
-- revoked.
|
||||
--
|
||||
-- `refresh_token` and `access_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 — the same fact worth knowing that migration 0014 wrote down.
|
||||
CREATE TABLE calendar_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'google',
|
||||
-- The address the account reports, so the settings page can say which one is
|
||||
-- connected rather than only that one is.
|
||||
account TEXT NOT NULL DEFAULT '',
|
||||
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Connecting again replaces what was there, the way Gmail's does: the OAuth flow
|
||||
-- is one account at a time, so a second row for the same provider would be a
|
||||
-- second set of tokens for the same grant and no way to say which is live.
|
||||
CREATE UNIQUE INDEX calendar_accounts_once ON calendar_accounts (user_id, provider);
|
||||
|
||||
-- One row per calendar the account can see, kept whether or not it is shown.
|
||||
--
|
||||
-- `shown` rather than deleting the ones that are not: a person who turns a calendar
|
||||
-- off and on again should get the same row back, and the list is refreshed from
|
||||
-- Google on every sync — so a calendar that only existed while it was ticked would
|
||||
-- lose its choice every time somebody added a new one.
|
||||
--
|
||||
-- `colour` is Google's own hex for the calendar. Not one of verdande's ten named
|
||||
-- colours, which is the rule everywhere else in this database: those are names so
|
||||
-- the interface can restyle them per theme, and they are *verdande's* palette. A
|
||||
-- Google calendar is told apart by the colour the person already knows it by, and
|
||||
-- that colour belongs to Google.
|
||||
CREATE TABLE calendars (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES calendar_accounts (id) ON DELETE CASCADE,
|
||||
-- The id Google knows it by. Long, and an address for the primary calendar.
|
||||
remote_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
colour TEXT NOT NULL DEFAULT '',
|
||||
time_zone TEXT NOT NULL DEFAULT '',
|
||||
primary_one INTEGER NOT NULL DEFAULT 0,
|
||||
writable INTEGER NOT NULL DEFAULT 0,
|
||||
shown INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX calendars_once ON calendars (account_id, remote_id);
|
||||
|
||||
-- The events themselves, cached.
|
||||
--
|
||||
-- Cached rather than fetched per view, and the reason is what a month grid does:
|
||||
-- it pages. Asking Google on every arrow press means a token refresh, a round trip
|
||||
-- and a spinner for a question that was answered ninety seconds ago — and a
|
||||
-- calendar that is slower than the calendar it is showing is not worth having.
|
||||
-- The window is refreshed by the sweep and by the "fetch now" button.
|
||||
--
|
||||
-- Days rather than instants, and both ends inclusive. A grid asks one question —
|
||||
-- "which cells does this cover" — and `start_day <= ? AND end_day >= ?` is that
|
||||
-- question. `starts_at` is kept as Google wrote it, offset and all, because it is
|
||||
-- what the chip shows and reparsing it here would ask *this process* what time
|
||||
-- zone it is in. An all-day event has neither: it has a day, not a moment, and
|
||||
-- inventing midnight for it is how an event moves a day when a container moves
|
||||
-- time zone.
|
||||
CREATE TABLE calendar_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
calendar_id TEXT NOT NULL REFERENCES calendars (id) ON DELETE CASCADE,
|
||||
remote_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
starts_at TEXT NOT NULL DEFAULT '',
|
||||
ends_at TEXT NOT NULL DEFAULT '',
|
||||
start_day TEXT NOT NULL,
|
||||
end_day TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- The one query there is: everything a person can see, between two days. Ordered
|
||||
-- by the account so the join has somewhere to start, then by the days the grid
|
||||
-- compares against.
|
||||
CREATE INDEX calendar_events_by_day ON calendar_events (calendar_id, start_day, end_day);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- A note can be put away without being thrown away.
|
||||
--
|
||||
-- The trash already exists and is the wrong tool. It says "this was a mistake,
|
||||
-- and in thirty days it is gone" — so anything worth keeping cannot go in it, and
|
||||
-- an archive of twelve hundred imported notes is exactly the case where most of
|
||||
-- them are worth keeping and almost none are worth looking at. Without somewhere
|
||||
-- to put them, the list is the whole archive forever, and finding this week's note
|
||||
-- means reading past a decade.
|
||||
--
|
||||
-- A column rather than a folder. Verdande already refused a second hierarchy for
|
||||
-- notes — see 0018 — and archiving is not filing: it is one bit about whether a
|
||||
-- note is current, and it has to be answerable next to `deleted_at`, which is the
|
||||
-- other bit of the same kind.
|
||||
--
|
||||
-- Nullable timestamp rather than a boolean, for the same reason `deleted_at` is
|
||||
-- one: "when was this put away" answers questions a flag cannot, and it costs the
|
||||
-- same byte.
|
||||
ALTER TABLE notes ADD COLUMN archived_at INTEGER;
|
||||
|
||||
-- The list asks "not deleted, not archived, newest first" on every load, and with
|
||||
-- twelve hundred notes that is the query that has to be quick.
|
||||
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- One row per person *and scope*, which is what the table always meant.
|
||||
--
|
||||
-- `user_settings` was created with `user_id` as its whole primary key, and its
|
||||
-- upsert rewrote `scope` on conflict. So a person had exactly one settings row,
|
||||
-- and whichever scope was written last was the only one that survived: saving an
|
||||
-- AI provider deleted the Gmail connection, and connecting a calendar deleted the
|
||||
-- AI provider. Nothing reported it — the write succeeded, and the loss was in the
|
||||
-- row it landed on.
|
||||
--
|
||||
-- This was seen coming twice. Migration 0011 gave the instance's settings their
|
||||
-- own table partly for this reason and wrote the warning down in as many words;
|
||||
-- 0014 moved mailboxes out and said the warning "was nearly walked into a second
|
||||
-- time". The third time was the calendar, which added a `calendar` scope beside
|
||||
-- `ai` and `gmail` — and walked in.
|
||||
--
|
||||
-- The lesson worth keeping: a warning in a migration is read by whoever is editing
|
||||
-- *that* migration, and nobody was. The shape is fixed here so there is nothing
|
||||
-- left to warn about.
|
||||
--
|
||||
-- SQLite cannot change a primary key in place, so the table is rebuilt. Existing
|
||||
-- rows are copied as they are: each person has at most one, and it holds whatever
|
||||
-- scope won last. What the earlier writes lost is already gone and cannot be
|
||||
-- recovered here — the values were overwritten, not orphaned.
|
||||
CREATE TABLE user_settings_new (
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT 'general',
|
||||
values_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, scope)
|
||||
);
|
||||
|
||||
INSERT INTO user_settings_new (user_id, scope, values_json, updated_at)
|
||||
SELECT user_id, scope, values_json, updated_at FROM user_settings;
|
||||
|
||||
DROP TABLE user_settings;
|
||||
|
||||
ALTER TABLE user_settings_new RENAME TO user_settings;
|
||||