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.)
|
||||||
@@ -67,5 +67,9 @@ node_modules.nosync/
|
|||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
NOTES-PLAN.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.
|
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||||
.claude/worktrees/
|
.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.
|
||||||
@@ -104,15 +104,21 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
| 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, one-way to tasks | Done, tested |
|
||||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
| 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 |
|
| Update notice for administrators | Done, tested |
|
||||||
| Personal API tokens | Done, tested |
|
| Personal API tokens | Done, tested |
|
||||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||||
| End-to-end smoke tests (Playwright) | Done |
|
| End-to-end smoke tests (Playwright) | Done |
|
||||||
| Documentation, landing page, licence | Done |
|
| Documentation, landing page, licence | Done |
|
||||||
|
|
||||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||||
API calls are implemented against their specifications and have only ever been
|
implemented against their specifications and have only ever been exercised there,
|
||||||
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
|
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||||
templates.
|
templates.
|
||||||
|
|||||||
@@ -39,8 +39,28 @@ reply within a week.
|
|||||||
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
||||||
page, rather than allowing `unsafe-inline`.
|
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
|
## What it does not do
|
||||||
|
|
||||||
verdande has no rate limiting beyond the login and password-reset endpoints, and
|
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
|
||||||
no audit log of reads. It is built to be run behind something — a Cloudflare
|
password, the second factor, the installation beacon, and inbound mail. **Nothing
|
||||||
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
|
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,22 @@ func run() error {
|
|||||||
}
|
}
|
||||||
db.UseSecrets(box)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Version = version
|
httpapi.Version = version
|
||||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Calendar and CalDAV
|
# Calendar and CalDAV
|
||||||
|
|
||||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
Two ways to get your tasks into a calendar. Getting somebody else's calendar
|
||||||
verdande, and none of the three is the same thing.
|
*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) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
| | [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 |
|
| 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 |
|
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||||
@@ -42,39 +43,10 @@ most people actually keep.
|
|||||||
|
|
||||||
## Showing a Google calendar in verdande
|
## Showing a Google calendar in verdande
|
||||||
|
|
||||||
The two sections above send your tasks *out*. This is the other direction: your
|
Moved. Connecting a Google account, subscribing to an address, and what the grid
|
||||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
does with either now live on their own page — see [Calendar](calendar.md).
|
||||||
already have in it".
|
|
||||||
|
|
||||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
This page is the other direction: verdande's own tasks, read by something else.
|
||||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
|
||||||
own colour for each one, beside your own tasks with due dates.
|
|
||||||
|
|
||||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
|
||||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
|
||||||
|
|
||||||
!!! warning "Read only"
|
|
||||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
|
||||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
|
||||||
can be changed. Your own tasks in the same grid still drag to another day as
|
|
||||||
they always have.
|
|
||||||
|
|
||||||
Writing would need the `calendar.events` scope and an answer to what should
|
|
||||||
happen when both sides moved the same meeting — which is a synchronisation
|
|
||||||
model rather than a scope, and it is deliberately not built yet.
|
|
||||||
|
|
||||||
!!! note "The Internal client applies here too"
|
|
||||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
|
||||||
used by accounts in that workspace, calendars included. A personal
|
|
||||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
|
||||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
|
||||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
|
||||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
|
||||||
this feature.
|
|
||||||
|
|
||||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
|
||||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
|
||||||
says so rather than showing an empty month.
|
|
||||||
|
|
||||||
## Connecting a CalDAV client
|
## Connecting a CalDAV client
|
||||||
|
|
||||||
|
|||||||
@@ -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_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. |
|
| `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
|
## Sessions and links
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1396,6 +1396,57 @@ paths:
|
|||||||
description: The environment variables that are not set.
|
description: The environment variables that are not set.
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"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:
|
/api/v1/notes/{noteID}/attachments:
|
||||||
post:
|
post:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -1998,6 +2049,125 @@ paths:
|
|||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"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}:
|
/api/v1/sections/{sectionID}:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
||||||
@@ -2902,6 +3072,69 @@ paths:
|
|||||||
"204": { description: Deleted }
|
"204": { description: Deleted }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"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}:
|
/api/v1/notes/linking/{kind}/{targetID}:
|
||||||
get:
|
get:
|
||||||
tags: [notes]
|
tags: [notes]
|
||||||
@@ -3737,6 +3970,14 @@ components:
|
|||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
updated_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:
|
links:
|
||||||
type: array
|
type: array
|
||||||
description: What the body points at, read out of the text on the way in.
|
description: What the body points at, read out of the text on the way in.
|
||||||
@@ -3753,6 +3994,21 @@ components:
|
|||||||
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||||
pinned: { type: boolean }
|
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:
|
Mailbox:
|
||||||
type: object
|
type: object
|
||||||
description: One connected mailbox. The password and any token are never returned.
|
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:
|
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 —
|
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
|
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||||
without saying so.
|
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
|
`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
|
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||||
looks locally — then reports the image as missing.
|
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 them itself with each request. Yggdrasil is a different client and
|
||||||
sends none, so logging in changes nothing for the panel.
|
sends none, so logging in changes nothing for the panel.
|
||||||
|
|
||||||
**Make the package public.** Under
|
**The package has to be readable without credentials**, and the only way to know
|
||||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
is to ask the registry for a token as a stranger:
|
||||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
|
||||||
readable, which is what the manifest and these pages have always assumed.
|
```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
|
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
|
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:
|
before creating the server, and again after every release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo docker login ghcr.io -u kristianwind
|
sudo docker login gitea.nolimit.dk -u kw
|
||||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
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
|
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 216 KiB |
@@ -6,9 +6,10 @@ cache, no queue and no second process.
|
|||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
!!! note "If the pull is refused"
|
!!! note "If the pull is refused"
|
||||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
The image is published from a self-hosted Gitea and is readable without
|
||||||
back to private, `docker login ghcr.io` with a token carrying the
|
credentials — the package is not linked to a repository, so its visibility
|
||||||
`read:packages` scope before pulling — and see
|
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
|
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||||
fails differently and much more confusingly under Yggdrasil.
|
fails differently and much more confusingly under Yggdrasil.
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ type Config struct {
|
|||||||
// ProviderCompatible and useful for a proxy in front of the others.
|
// ProviderCompatible and useful for a proxy in front of the others.
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Model 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 {
|
func (c Config) Configured() bool {
|
||||||
@@ -73,13 +79,17 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Client {
|
func New(cfg Config) *Client {
|
||||||
|
dial := safedial.Client
|
||||||
|
if cfg.AllowPrivate {
|
||||||
|
dial = safedial.AllowPrivate
|
||||||
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
// Generous: a local model on modest hardware can take a while to produce
|
// 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
|
// its first token, and the alternative is a timeout that only ever fires
|
||||||
// for the people running their own.
|
// for the people running their own.
|
||||||
// safedial, not a plain client. `base_url` is typed into a field in the
|
// safedial by default. `base_url` is typed into a field in the interface,
|
||||||
// interface, so it is a user-supplied address — and a server that fetches a
|
// 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
|
// 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
|
// 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.
|
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||||
@@ -87,7 +97,13 @@ func New(cfg Config) *Client {
|
|||||||
// The check is on the resolved address rather than on the URL, because a
|
// 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
|
// name answers whatever its owner says — and can answer differently the
|
||||||
// second time, after a parse-time check has passed.
|
// second time, after a parse-time check has passed.
|
||||||
http: safedial.Client(120 * time.Second),
|
//
|
||||||
|
// 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", "k@example.com")
|
||||||
|
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,
|
// codeAt produces the code an authenticator app would display at that moment,
|
||||||
// using the same settings VerifyTOTP checks against.
|
// using the same settings VerifyTOTP checks against.
|
||||||
func codeAt(t *testing.T, secret string, now time.Time) string {
|
func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -82,6 +83,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
|
|||||||
return nil
|
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,
|
// 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 hashes to store. Without these, losing a device means losing the account,
|
||||||
// and the only fix left is editing the database by hand.
|
// and the only fix left is editing the database by hand.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -80,6 +81,26 @@ type Config struct {
|
|||||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||||
TrashRetention time.Duration
|
TrashRetention time.Duration
|
||||||
|
|
||||||
|
// 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
|
Dev bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +170,70 @@ func Load() (*Config, error) {
|
|||||||
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
||||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
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
|
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.
|
// 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) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
||||||
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
||||||
|
|||||||
@@ -218,7 +218,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
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 {
|
if err != nil {
|
||||||
// A recovery code is accepted in the same field. Making somebody find a
|
// 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
|
// different form while locked out of their account is a poor time to
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ type testServer struct {
|
|||||||
*httptest.Server
|
*httptest.Server
|
||||||
db *store.DB
|
db *store.DB
|
||||||
client *http.Client
|
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 {
|
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.
|
// Discard: a passing test should print nothing, and these handlers log freely.
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
jar, err := cookiejar.New(nil)
|
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
|
// The cookie jar is what makes this a session test rather than a
|
||||||
// sequence of unrelated requests.
|
// sequence of unrelated requests.
|
||||||
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
||||||
|
api: api,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +372,50 @@ 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": "kristian@example.dk", "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": "kristian@example.dk", "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.
|
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
||||||
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
|
|||||||
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
if getErr == nil {
|
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;
|
// An update. Only the fields a task client can express are touched;
|
||||||
// everything else the task carries stays as it is.
|
// everything else the task carries stays as it is.
|
||||||
content := parsed.Summary
|
content := parsed.Summary
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/gcal"
|
"github.com/kristianwind/verdande/internal/gcal"
|
||||||
|
"github.com/kristianwind/verdande/internal/ics"
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,9 +212,21 @@ type calendarStatusJSON struct {
|
|||||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
// 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.
|
// an interface that quietly looks editable is one somebody will try to edit.
|
||||||
ReadOnly bool `json:"read_only"`
|
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) {
|
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())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
out := calendarStatusJSON{
|
out := calendarStatusJSON{
|
||||||
@@ -223,6 +242,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if account == nil || account.RefreshToken == "" {
|
if account == nil || account.RefreshToken == "" {
|
||||||
|
out.Subscriptions = subscriptions
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -253,6 +273,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
if calendars != nil {
|
if calendars != nil {
|
||||||
out.Calendars = calendars
|
out.Calendars = calendars
|
||||||
}
|
}
|
||||||
|
out.Subscriptions = subscriptions
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,12 +404,23 @@ func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Returns how many events were written. Also used by the background job, which is
|
// 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.
|
// why it takes a context and a user rather than a request.
|
||||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
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)
|
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return written, err
|
||||||
}
|
}
|
||||||
if account == nil || account.RefreshToken == "" {
|
if account == nil || account.RefreshToken == "" {
|
||||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
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
|
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
||||||
@@ -414,7 +446,6 @@ func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
from, to := calendarWindow(time.Now())
|
from, to := calendarWindow(time.Now())
|
||||||
written := 0
|
|
||||||
for _, calendar := range calendars {
|
for _, calendar := range calendars {
|
||||||
if !calendar.Shown {
|
if !calendar.Shown {
|
||||||
continue
|
continue
|
||||||
@@ -546,3 +577,232 @@ func validDay(s string) bool {
|
|||||||
_, err := time.Parse(calendarDateLayout, s)
|
_, err := time.Parse(calendarDateLayout, s)
|
||||||
return err == nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||||
@@ -50,6 +52,39 @@ func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Et bilag må ikke kunne blive læst som et dokument.
|
||||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||||
|
|||||||
@@ -360,8 +360,19 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
Description: msg.Snippet + "\n\n" + msg.Link,
|
Description: msg.Snippet + "\n\n" + msg.Link,
|
||||||
Priority: 4,
|
Priority: 4,
|
||||||
CreatedBy: user.ID,
|
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 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)
|
s.log.Warn("gmail create task", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,20 +215,31 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address is checked where it is typed, as well as when it is dialled.
|
// 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.
|
||||||
//
|
//
|
||||||
// safedial refuses a private destination at connect time and is the actual
|
// The private-network wall has one exception, and it is the point of the
|
||||||
// wall; this is so the person entering it hears about it now rather than
|
// provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model
|
||||||
// discovering it later as a failed summary. A scheme other than http or https
|
// on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150.
|
||||||
// is refused outright — file:// and gopher:// are not providers.
|
// 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 url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if reason := safedial.CheckURL(url); reason != "" {
|
if !user.IsAdmin {
|
||||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
if reason := safedial.CheckURL(url); reason != "" {
|
||||||
return
|
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
req.BaseURL = url
|
req.BaseURL = url
|
||||||
}
|
}
|
||||||
@@ -265,11 +276,20 @@ func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
|
|||||||
v, _ := values[key].(string)
|
v, _ := values[key].(string)
|
||||||
return v
|
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{
|
return ai.Config{
|
||||||
Provider: ai.Provider(str("provider")),
|
Provider: ai.Provider(str("provider")),
|
||||||
APIKey: str("api_key"),
|
APIKey: str("api_key"),
|
||||||
BaseURL: str("base_url"),
|
BaseURL: str("base_url"),
|
||||||
Model: str("model"),
|
Model: str("model"),
|
||||||
|
AllowPrivate: allowPrivate,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- MCP -----------------------------------------------------------------------------
|
// --- MCP -----------------------------------------------------------------------------
|
||||||
@@ -250,6 +255,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
|
|||||||
return token
|
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, "viewer@example.dk", "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": "viewer@example.dk", "role": "viewer",
|
||||||
|
}); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
them, err := ts.db.UserByEmail(t.Context(), "viewer@example.dk")
|
||||||
|
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(), "kristian@example.dk")
|
||||||
|
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) {
|
func TestCalDAVRequiresCredentials(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(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(), "kristian@example.dk")
|
||||||
|
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, "andreas@example.dk", "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) {
|
func TestGmailSettings(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
@@ -1316,3 +1553,172 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
|
|||||||
t.Error("the second rotation returned the same address")
|
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(), "kristian@example.dk")
|
||||||
|
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:en@example.dk\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(), "kristian@example.dk")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,8 +202,20 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
|||||||
Description: msg.Snippet,
|
Description: msg.Snippet,
|
||||||
Priority: 4,
|
Priority: 4,
|
||||||
CreatedBy: user.ID,
|
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 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)
|
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,21 +19,38 @@ import (
|
|||||||
|
|
||||||
// Notes.
|
// Notes.
|
||||||
//
|
//
|
||||||
// Access follows the project, because that is where a note lives: a note in a
|
// Access has three ways in, and a note may have any of them at once. A note with
|
||||||
// shared project is readable by everybody who can read the project and writable
|
// no project is its author's alone — the same answer a task in the inbox gets. A
|
||||||
// by an editor. A note with no project is its author's alone — the same answer a
|
// note in a project is readable by everyone who can read the project and writable
|
||||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
// 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
|
// mayTouchNote reports whether this person may act on the note at the given role —
|
||||||
// whether they may change it. Split, because a viewer on a shared project can do
|
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
||||||
// the first and not the second.
|
// 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 {
|
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
// The owner and the project path, exactly as before.
|
||||||
if n.ProjectID == "" {
|
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)
|
// A direct share grants its role on top of whatever the above allow.
|
||||||
return err == nil
|
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) {
|
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "search notes", err)
|
s.internal(w, r, "search notes", err)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +79,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "notes in project", err)
|
s.internal(w, r, "notes in project", err)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +104,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "list notes", err)
|
s.internal(w, r, "list notes", err)
|
||||||
return
|
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.
|
// readable drops what this person may not see.
|
||||||
@@ -159,9 +223,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if body.ProjectID != nil {
|
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
||||||
// Moving it means being allowed on both sides: you cannot file somebody
|
// Filing a note is the owner's call, not an editor's. A shared editor may
|
||||||
// else's note into your project, nor yours into theirs.
|
// 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 *body.ProjectID != "" {
|
||||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||||
@@ -191,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.internal(w, r, "delete note", err)
|
s.internal(w, r, "delete note", err)
|
||||||
return
|
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")
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,6 +313,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition",
|
w.Header().Set("Content-Disposition",
|
||||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||||
@@ -687,3 +779,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
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,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, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
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, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
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, "sofie@example.dk", "Sofie")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
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, "sofie@example.dk", "Sofie")
|
||||||
|
owner.newUser(t, "anders@example.dk", "Anders")
|
||||||
|
sofie := userID(t, owner, "sofie@example.dk")
|
||||||
|
|
||||||
|
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, "kristian@example.dk")
|
||||||
|
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)
|
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)
|
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||||
notes := listed["notes"].([]any)
|
notes := listed["notes"].([]any)
|
||||||
if len(notes) != 1 {
|
if len(notes) != 1 {
|
||||||
t.Fatalf("found %d notes", len(notes))
|
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
|
// The link points at the file where it now lives, not at a path inside an
|
||||||
// archive nobody kept.
|
// archive nobody kept.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -106,18 +108,44 @@ func (s *Server) PushToUser(userID, title, body, projectID string) {
|
|||||||
s.pushToUser(userID, title, body, projectID)
|
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)
|
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
var out pushOutcome
|
||||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || len(subs) == 0 {
|
||||||
return
|
return out
|
||||||
}
|
}
|
||||||
|
out.Subscriptions = len(subs)
|
||||||
|
|
||||||
public, private, err := s.vapidKeys(ctx)
|
public, private, err := s.vapidKeys(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("vapid keys", "err", err)
|
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}
|
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||||
@@ -128,13 +156,101 @@ func (s *Server) pushToUser(userID, title, body, projectID string) {
|
|||||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
out.Sent++
|
||||||
continue
|
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 {
|
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
||||||
s.log.Warn("record push failure", "err", recErr)
|
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) {
|
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||||
|
|||||||
@@ -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})
|
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
|
// 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
|
// it. Sections have no permissions of their own — they belong to a project, and
|
||||||
// that is where access is decided.
|
// 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.
|
// A project can be created straight into a group.
|
||||||
//
|
//
|
||||||
// `group_id` was declared on the request type, documented on it, and applied by
|
// `group_id` was declared on the request type, documented on it, and applied by
|
||||||
|
|||||||
@@ -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,6 +63,9 @@ type Server struct {
|
|||||||
// the endpoints that check a secret are limited separately and more tightly
|
// the endpoints that check a secret are limited separately and more tightly
|
||||||
// than the rest of the API.
|
// than the rest of the API.
|
||||||
loginLimiter *limiter
|
loginLimiter *limiter
|
||||||
|
// Sat kun af prøver; se icsClient().
|
||||||
|
icsFetch *http.Client
|
||||||
|
mailLimiter *limiter
|
||||||
resetLimiter *limiter
|
resetLimiter *limiter
|
||||||
// Second factors are counted against the *account*, not the address. An
|
// Second factors are counted against the *account*, not the address. An
|
||||||
// address bucket is defeated by anybody with more than one address, which is
|
// address bucket is defeated by anybody with more than one address, which is
|
||||||
@@ -85,6 +88,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||||
hub: realtime.NewHub(log),
|
hub: realtime.NewHub(log),
|
||||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
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),
|
resetLimiter: newLimiter(5, time.Hour),
|
||||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
// 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
|
// their own code gets it right on the first or second try; ten in an hour is
|
||||||
@@ -102,7 +110,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
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(requestLogger(log))
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
@@ -122,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
|
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||||
// authenticated by the token in the recipient address.
|
// 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
|
// 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
|
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||||
@@ -281,6 +300,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/key", s.handlePushKey)
|
r.Get("/key", s.handlePushKey)
|
||||||
r.Post("/subscribe", s.handleSubscribePush)
|
r.Post("/subscribe", s.handleSubscribePush)
|
||||||
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
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) {
|
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
||||||
@@ -326,6 +348,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/", s.handleCreateNote)
|
r.Post("/", s.handleCreateNote)
|
||||||
r.Post("/import", s.handleImportNotes)
|
r.Post("/import", s.handleImportNotes)
|
||||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
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.Get("/{noteID}", s.handleGetNote)
|
||||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||||
@@ -333,6 +360,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
// files in and nothing else could, which made the most ordinary act
|
// files in and nothing else could, which made the most ordinary act
|
||||||
// in a notes app — paste a screenshot — do nothing at all.
|
// in a notes app — paste a screenshot — do nothing at all.
|
||||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
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) {
|
r.Route("/mailboxes", func(r chi.Router) {
|
||||||
@@ -372,6 +402,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||||
r.Post("/sync", s.handleCalendarSyncNow)
|
r.Post("/sync", s.handleCalendarSyncNow)
|
||||||
r.Get("/events", s.handleCalendarEvents)
|
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.Route("/import", func(r chi.Router) {
|
||||||
@@ -423,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.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireProject(store.RoleEditor))
|
r.Use(s.requireProject(store.RoleEditor))
|
||||||
r.Post("/sections", s.handleCreateSection)
|
r.Post("/sections", s.handleCreateSection)
|
||||||
|
r.Post("/sections/reorder", s.handleReorderSections)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -636,6 +673,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Referrer-Policy", "same-origin")
|
h.Set("Referrer-Policy", "same-origin")
|
||||||
h.Set("Content-Security-Policy", s.csp)
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1777,3 +1778,42 @@ func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
|||||||
t.Errorf("content = %v", moved["content"])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -367,3 +367,180 @@ func unescape(s string) string {
|
|||||||
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
|
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
|
||||||
return r.Replace(s)
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -220,3 +220,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:heldag@example.dk\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:utc@example.dk\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:lokal@example.dk\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:flerdage@example.dk\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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/safedial"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Subscription struct {
|
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("Urgency", "normal")
|
||||||
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
|
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 {
|
if err != nil {
|
||||||
return err
|
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:kristian@example.dk"}
|
||||||
|
}
|
||||||
@@ -650,8 +650,18 @@ func pad2(n int) string {
|
|||||||
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
|
// 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
|
// 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.
|
// 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(
|
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` +
|
`|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|$)`)
|
`|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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,17 +24,58 @@
|
|||||||
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
// 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
|
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
||||||
// wall around the inside.
|
// wall around the inside.
|
||||||
|
//
|
||||||
|
// With one named exception, for Tailscale — see tailnets below.
|
||||||
package safedial
|
package safedial
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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
|
// 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
|
// deliberately does not say which address it resolved to: the answer is itself
|
||||||
// the thing an attacker is fishing for.
|
// the thing an attacker is fishing for.
|
||||||
@@ -46,8 +87,15 @@ func (e *ErrPrivate) Error() string {
|
|||||||
|
|
||||||
// Blocked reports whether an address must not be dialled.
|
// Blocked reports whether an address must not be dialled.
|
||||||
func Blocked(ip net.IP) bool {
|
func Blocked(ip net.IP) bool {
|
||||||
return ip == nil ||
|
if ip == nil {
|
||||||
ip.IsLoopback() ||
|
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.IsUnspecified() ||
|
||||||
ip.IsLinkLocalUnicast() ||
|
ip.IsLinkLocalUnicast() ||
|
||||||
ip.IsLinkLocalMulticast() ||
|
ip.IsLinkLocalMulticast() ||
|
||||||
|
|||||||
@@ -53,3 +53,47 @@ func TestAllowPrivateStillReaches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
resp.Body.Close()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +16,12 @@ type CalendarAccount struct {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Account string `json:"account,omitempty"`
|
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
|
RefreshToken string `json:"-"` // never leaves the server
|
||||||
AccessToken string `json:"-"`
|
AccessToken string `json:"-"`
|
||||||
ExpiresAt time.Time `json:"-"`
|
ExpiresAt time.Time `json:"-"`
|
||||||
@@ -65,10 +73,17 @@ type CalendarEvent struct {
|
|||||||
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
||||||
access_token, expires_at, last_sync_at, created_at`
|
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.
|
// CalendarAccountFor returns a person's connection to one provider, or nil.
|
||||||
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
|
// `url = ''`: en OAuth-konto. Uden det ville et abonnement hos den samme
|
||||||
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
|
// 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)
|
a, err := db.scanCalendarAccount(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -106,27 +121,37 @@ func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error
|
|||||||
// struct with no id in it, and matching on the id would insert a second row
|
// 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
|
// that the unique index then refuses — which reads to the person as "connecting
|
||||||
// failed" when what happened is that it already had.
|
// 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, `
|
_, err = db.ExecContext(ctx, `
|
||||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
|
INSERT INTO calendar_accounts (`+calendarAccountColumns+`, url)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (user_id, provider) DO UPDATE SET
|
ON CONFLICT (user_id, provider) WHERE url = '' DO UPDATE SET
|
||||||
account = excluded.account,
|
account = excluded.account,
|
||||||
refresh_token = excluded.refresh_token,
|
refresh_token = excluded.refresh_token,
|
||||||
access_token = excluded.access_token,
|
access_token = excluded.access_token,
|
||||||
expires_at = excluded.expires_at,
|
expires_at = excluded.expires_at,
|
||||||
last_sync_at = excluded.last_sync_at`,
|
last_sync_at = excluded.last_sync_at`,
|
||||||
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
||||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
|
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix(), a.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if a.URL != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// The row that is actually there wins. On a reconnect the insert above updated
|
// 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
|
// 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
|
// calendars are hung off the id, so writing them against it would attach them
|
||||||
// to nothing.
|
// to nothing.
|
||||||
return db.QueryRowContext(ctx,
|
return db.QueryRowContext(ctx,
|
||||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
|
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`,
|
||||||
a.UserID, a.Provider).Scan(&a.ID)
|
a.UserID, a.Provider).Scan(&a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +387,7 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
|||||||
var a CalendarAccount
|
var a CalendarAccount
|
||||||
var expires, lastSync, created int64
|
var expires, lastSync, created int64
|
||||||
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
||||||
&a.AccessToken, &expires, &lastSync, &created)
|
&a.AccessToken, &expires, &lastSync, &created, &a.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
@@ -378,3 +403,129 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
|||||||
a.CreatedAt = time.Unix(created, 0)
|
a.CreatedAt = time.Unix(created, 0)
|
||||||
return a, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,10 +195,14 @@ func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
|
||||||
`"email":"kw@nolimit.dk","seen":["abc","def"],"project_id":"p-1"}`
|
`"email":"kw@nolimit.dk","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(), `
|
if _, err := db.ExecContext(context.Background(), `
|
||||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
VALUES (?, 'gmail', ?, 0)
|
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 {
|
userID, values); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Abonnement på en kalender, man kun har en adresse til.
|
||||||
|
--
|
||||||
|
-- Google-vejen er OAuth: én konto pr. person, hvor tokenet er identiteten, og
|
||||||
|
-- `calendar_accounts_once` sørgede for netop det — én række pr. (person,
|
||||||
|
-- udbyder). Et abonnement er en anden slags ting. Der er intet token og ingen
|
||||||
|
-- konto, kun en adresse, og man har flere: helligdage, en fodboldklub, en
|
||||||
|
-- fælleskalender fra arbejdet. Det er derfor det blev bedt om som "flere
|
||||||
|
-- kalendere".
|
||||||
|
--
|
||||||
|
-- Samme tabel frem for en ny. Et abonnement er én kalender med begivenheder i, og
|
||||||
|
-- det er præcis det, `calendars` og `calendar_events` allerede beskriver — med
|
||||||
|
-- vinduet, `shown`, farven og hele fejemaskinen omkring sig. En tabel til ville
|
||||||
|
-- være en anden vej til det samme sted, og to veje til ét sted er to steder at
|
||||||
|
-- rette den dag noget om begivenheder ændrer sig.
|
||||||
|
--
|
||||||
|
-- Adressen bliver identiteten, 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, og der er ingen måde at sige hvilken der gælder.
|
||||||
|
ALTER TABLE calendar_accounts ADD COLUMN url TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Det gamle indeks holder stadig for OAuth-rækkerne — dem uden adresse — og
|
||||||
|
-- slipper abonnementerne fri. Delvise indekser er den ene ting SQLite kan her,
|
||||||
|
-- som en tabelombygning ellers skulle til for.
|
||||||
|
DROP INDEX calendar_accounts_once;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX calendar_accounts_once
|
||||||
|
ON calendar_accounts (user_id, provider) WHERE url = '';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX calendar_subscription_once
|
||||||
|
ON calendar_accounts (user_id, url) WHERE url <> '';
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- verdande:rebuild-tables
|
||||||
|
--
|
||||||
|
-- Notekroppe forsegles.
|
||||||
|
--
|
||||||
|
-- Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
|
||||||
|
-- sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem
|
||||||
|
-- hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler
|
||||||
|
-- ned — det ved vi, fordi der ligger sådan nogle i denne installation.
|
||||||
|
--
|
||||||
|
-- # Hvorfor det ikke var gjort for længe siden
|
||||||
|
--
|
||||||
|
-- Kroppen var kopieret to steder mere, og begge kopier var klartekst:
|
||||||
|
--
|
||||||
|
-- * `fold` — en genereret søjle med title || ' ' || body, translittereret, så
|
||||||
|
-- "grøn" kan findes ved at skrive "gron".
|
||||||
|
-- * `notes_fts` — et FTS5-indeks over titel, krop og fold.
|
||||||
|
--
|
||||||
|
-- At forsegle kroppen og lade dem stå ville have været teater: en angriber med
|
||||||
|
-- filen kunne læse hver note ud af indekset. Så de skal begge væk, og søgningen
|
||||||
|
-- skal skrives om til at åbne teksten i Go.
|
||||||
|
--
|
||||||
|
-- # Hvad det koster, og hvornår formen er forkert
|
||||||
|
--
|
||||||
|
-- Søgningen læser nu hver note. Målt: tolv hundrede noter à 3,3 kB koster 33–43 ms
|
||||||
|
-- pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så
|
||||||
|
-- det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne, men med at
|
||||||
|
-- folde og småskrive hver krop; det er lineært, så regn med omtrent 350 ms ved ti
|
||||||
|
-- tusind noter. Dér er det den forkerte form, og svaret er da et *nøglet* indeks:
|
||||||
|
-- en HMAC pr. ord under den samme nøgle. Bemærk hvad det koster, når det bliver
|
||||||
|
-- aktuelt — et nøglet indeks kan slå et helt ord op og ikke andet, så præfiks- og
|
||||||
|
-- delordssøgningen, der findes i dag, ville falde bort.
|
||||||
|
--
|
||||||
|
-- # Hvad der stadig ligger i klartekst
|
||||||
|
--
|
||||||
|
-- Alt andet. Opgavetitler, beskrivelser, kommentarer — og `note_links`, som er
|
||||||
|
-- id'er udledt af kroppen, så en fil røber stadig *hvilke* projekter og opgaver en
|
||||||
|
-- note peger på, men ikke hvad der står. Det er skrevet i SECURITY.md, og det er
|
||||||
|
-- ikke løst her.
|
||||||
|
--
|
||||||
|
-- # Selve ombygningen
|
||||||
|
--
|
||||||
|
-- SQLite kan ikke fjerne en genereret søjle på plads, så tabellen bygges om.
|
||||||
|
-- Rækkerne kopieres med kroppen, som den er: forseglingen sker i Go, hvor nøglen
|
||||||
|
-- er, og en opstart efter denne migration går rækkerne igennem og lukker dem. Til
|
||||||
|
-- den tid kan en søjle indeholde begge dele, og `v1:`-præfikset findes netop for
|
||||||
|
-- at kunne læses uden at gætte.
|
||||||
|
CREATE TABLE notes_sealed (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
archived_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO notes_sealed
|
||||||
|
(id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at)
|
||||||
|
SELECT id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at
|
||||||
|
FROM notes;
|
||||||
|
|
||||||
|
DROP TRIGGER notes_fts_insert;
|
||||||
|
DROP TRIGGER notes_fts_delete;
|
||||||
|
DROP TRIGGER notes_fts_update;
|
||||||
|
DROP TABLE notes_fts;
|
||||||
|
DROP TABLE notes;
|
||||||
|
|
||||||
|
ALTER TABLE notes_sealed RENAME TO notes;
|
||||||
|
|
||||||
|
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
|
||||||
|
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Kvasir: what an assistant did, and what it acted on.
|
||||||
|
--
|
||||||
|
-- Two things, and they are separate on purpose: one is about the outside world,
|
||||||
|
-- and would be worth having even if Kvasir never existed.
|
||||||
|
|
||||||
|
-- 1. Where a task came from, out there.
|
||||||
|
--
|
||||||
|
-- Nothing today can answer "has this mail already become a task?". A second
|
||||||
|
-- reader of the same mailbox — another assistant, a rerun of the same sweep,
|
||||||
|
-- a person — makes a second task, and the only sign is the duplicate itself.
|
||||||
|
-- That has happened in practice, twice in one run.
|
||||||
|
--
|
||||||
|
-- Namespaced rather than a bare id: `gmail:thread:18f2c…`, `imap:<host>:<uid>`,
|
||||||
|
-- `ics:<uid>`. A Gmail thread id and an IMAP uid look nothing alike until the
|
||||||
|
-- day they collide, and a namespace costs one colon. It also means the next
|
||||||
|
-- source needs no column — which is the point of a key rather than a foreign
|
||||||
|
-- key. There is no messages table to point at, and this has to work without one.
|
||||||
|
ALTER TABLE tasks ADD COLUMN source_key TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- The index is the interlock, not an optimisation. Two agents reading the same
|
||||||
|
-- inbox must be unable to make two tasks, and "must be unable" is a constraint
|
||||||
|
-- rather than a convention somebody remembers to follow.
|
||||||
|
--
|
||||||
|
-- Scoped to the author: two people on one instance connect their own mailboxes,
|
||||||
|
-- and the same thread reaching both of them is two tasks, correctly. Partial
|
||||||
|
-- because `created_by` is ON DELETE SET NULL — a task outlives its author, and
|
||||||
|
-- rows that have lost theirs must not start colliding with each other.
|
||||||
|
CREATE UNIQUE INDEX tasks_once_per_source
|
||||||
|
ON tasks (created_by, source_key)
|
||||||
|
WHERE source_key <> '' AND created_by IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Kvasir's ledger.
|
||||||
|
--
|
||||||
|
-- The dial that lets an assistant act on its own is only bearable because of
|
||||||
|
-- this table. Two questions have to be answerable at any moment: what did it do
|
||||||
|
-- today, and how do I undo it. Neither is answerable from the tasks themselves.
|
||||||
|
--
|
||||||
|
-- A suggestion and a completed action are the SAME ROW in different states. That
|
||||||
|
-- is the whole design: "suggest" and "act" are not two code paths, they are one
|
||||||
|
-- path that writes a different `state`. Accepting a suggestion moves it to done;
|
||||||
|
-- undoing an action moves it to undone. Nothing has to be written twice, and the
|
||||||
|
-- feed reads the same either way.
|
||||||
|
--
|
||||||
|
-- `before_json` is the undo. Not a diff and not a description — the fields as
|
||||||
|
-- they were, so putting them back needs no reasoning about what changed. It is
|
||||||
|
-- also the honest record: a task edited by hand afterwards will not match, and
|
||||||
|
-- an undo that would overwrite newer work should say so rather than win.
|
||||||
|
CREATE TABLE kvasir_actions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- 'file', 'rewrite', 'split', 'nudge', 'prioritise'. Free text rather than a
|
||||||
|
-- CHECK: a new capability must not need a migration to be recorded, and an
|
||||||
|
-- unknown kind in the feed is a row nobody rendered, not a broken database.
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- One sentence, written when the action was decided. Stored rather than
|
||||||
|
-- rebuilt: it says what Kvasir believed at the time, and the project it
|
||||||
|
-- names may since have been renamed or deleted.
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
-- Why it thought so. Shown on request, because a suggestion you cannot
|
||||||
|
-- interrogate is one you either trust blindly or ignore entirely.
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
before_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
after_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- suggested → done | dismissed, and done → undone.
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('suggested', 'done', 'dismissed', 'undone')),
|
||||||
|
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
decided_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The feed asks "what is waiting for me, newest first", and the corrections
|
||||||
|
-- Kvasir learns from ask "what did I get wrong lately" — both read by user and
|
||||||
|
-- order by time.
|
||||||
|
CREATE INDEX kvasir_actions_by_user ON kvasir_actions (user_id, created_at DESC);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A TOTP code is meant to be usable once. RFC 6238 says so in as many words: the
|
||||||
|
-- verifier must reject a code that has already been accepted, or a code observed in
|
||||||
|
-- flight — over the shoulder, off a phishing page, out of a proxy — can be replayed
|
||||||
|
-- for the rest of its thirty-second window.
|
||||||
|
--
|
||||||
|
-- totp_last_step records the counter of the most recently accepted code (Unix time
|
||||||
|
-- divided by the period). A login accepts a code only when its step is strictly
|
||||||
|
-- greater, so the same code cannot be spent twice and time cannot run backwards.
|
||||||
|
-- Null means no code has been accepted yet, which is every account until its owner
|
||||||
|
-- next signs in with one.
|
||||||
|
ALTER TABLE users ADD COLUMN totp_last_step INTEGER;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Sharing a note with a person, rather than only by filing it in a shared project.
|
||||||
|
--
|
||||||
|
-- Until now a note was shared exactly one way: put it in a project, and everyone
|
||||||
|
-- who could read the project could read the note. That is the right tool when the
|
||||||
|
-- note belongs with a body of work, and the wrong one when it is just "let Sofie
|
||||||
|
-- see this" — it makes you stand up a project to hand over a single page.
|
||||||
|
--
|
||||||
|
-- A note_shares row is that direct grant: this note, this person, this role. It
|
||||||
|
-- sits alongside the project path, not instead of it — a note can be loose, filed,
|
||||||
|
-- shared with people, or any combination, and access is the most that any of those
|
||||||
|
-- allow. Only the note's owner (its created_by) writes these rows.
|
||||||
|
--
|
||||||
|
-- role is 'viewer' or 'editor', the same words a project membership uses, so there
|
||||||
|
-- is one idea of what a role means in this program and not two.
|
||||||
|
CREATE TABLE note_shares (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- One share per person per note: sharing again changes the role, it does not
|
||||||
|
-- add a second row that the first would then quietly disagree with.
|
||||||
|
PRIMARY KEY (note_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Which notes are shared with me" is asked on every visit to the notes page, to
|
||||||
|
-- build the Delt med mig group and to widen what the list may show. It is a lookup
|
||||||
|
-- by user_id, so that is what carries the index.
|
||||||
|
CREATE INDEX note_shares_by_user ON note_shares (user_id);
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoteShare is one person's direct grant on a note: who they are, and what they
|
||||||
|
// may do. The person is carried whole rather than by id, because every caller of
|
||||||
|
// this wants to show them — a name and an avatar colour, never a bare id.
|
||||||
|
type NoteShare struct {
|
||||||
|
User Person `json:"user"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareNote grants a person a role on a note, or changes the role if they already
|
||||||
|
// had one. The owner is passed so the row records who did the sharing; the caller
|
||||||
|
// has already checked that they may.
|
||||||
|
//
|
||||||
|
// Sharing a note with its own owner is refused: the owner already has everything a
|
||||||
|
// share could give, and a row saying otherwise is a contradiction waiting to
|
||||||
|
// confuse the access check.
|
||||||
|
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
||||||
|
if !role.Valid() || role == RoleOwner {
|
||||||
|
return errors.New("store: a note can be shared as viewer or editor")
|
||||||
|
}
|
||||||
|
var createdBy sql.NullString
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if createdBy.String == userID {
|
||||||
|
return errors.New("store: a note is already the owner's")
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
noteID, userID, role, byUserID, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
||||||
|
// error: the end state the caller asked for — this person cannot see this note —
|
||||||
|
// is the same either way.
|
||||||
|
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
||||||
|
// name so the list under a note does not reshuffle every time it is opened.
|
||||||
|
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT u.id, u.name, u.avatar_color, s.role
|
||||||
|
FROM note_shares s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.note_id = ?
|
||||||
|
ORDER BY lower(u.name)`, noteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []NoteShare
|
||||||
|
for rows.Next() {
|
||||||
|
var s NoteShare
|
||||||
|
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteShareRole is the role a person holds on a note through a direct share, if
|
||||||
|
// any. The bool is whether a share exists at all — distinct from a share whose
|
||||||
|
// role could not be read — so a caller does not have to treat "no share" as an
|
||||||
|
// error.
|
||||||
|
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
||||||
|
var role Role
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if !role.Valid() {
|
||||||
|
// A role the database holds but this build does not understand is no access,
|
||||||
|
// never a guess — the same rule project roles follow.
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return role, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
||||||
|
// the user. It answers the Delt med mig question in one query rather than one per
|
||||||
|
// note, and takes the ids it is scoping so it never has to load a person's whole
|
||||||
|
// world to mark a single page.
|
||||||
|
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
||||||
|
out := map[string]bool{}
|
||||||
|
if len(noteIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
args := make([]any, 0, len(noteIDs)+1)
|
||||||
|
args = append(args, userID)
|
||||||
|
for _, id := range noteIDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -3,27 +3,51 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Note is a page of Markdown belonging to a project, or to nobody.
|
// Note is a page of Markdown belonging to a project, or to nobody.
|
||||||
type Note struct {
|
type Note struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
CreatedBy string `json:"created_by,omitempty"`
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
|
||||||
|
// Preview is the first of the body, for a list that shows one line of it.
|
||||||
|
//
|
||||||
|
// It exists so a list of twelve hundred notes does not have to carry twelve
|
||||||
|
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
|
||||||
|
// characters of each. When it is set, Body is deliberately empty — see
|
||||||
|
// handleListNotes. That is not tidiness but a guard: an editor handed a
|
||||||
|
// truncated body would save the truncation, and the note would be cut down to
|
||||||
|
// its own preview.
|
||||||
|
Preview string `json:"preview,omitempty"`
|
||||||
|
|
||||||
// Links is what the body points at, read out of the text on the way in. Never
|
// Links is what the body points at, read out of the text on the way in. Never
|
||||||
// set by a caller: it is derived, and a caller that set it would be describing
|
// set by a caller: it is derived, and a caller that set it would be describing
|
||||||
// a note whose text says something else.
|
// a note whose text says something else.
|
||||||
Links []NoteLink `json:"links,omitempty"`
|
Links []NoteLink `json:"links,omitempty"`
|
||||||
|
|
||||||
|
// SharedWithMe is true when the person reading the list reached this note
|
||||||
|
// through a direct share rather than by owning it or being in its project. It
|
||||||
|
// is what puts the note in the Delt med mig group and shows whose note it is.
|
||||||
|
// Derived per request, so it is never stored and never set by a caller.
|
||||||
|
SharedWithMe bool `json:"shared_with_me,omitempty"`
|
||||||
|
|
||||||
|
// Owner is who the note belongs to — its created_by — as a name and a colour,
|
||||||
|
// for the chip on a note shared with the reader. Set only when it helps the
|
||||||
|
// list say something; nil the rest of the time.
|
||||||
|
Owner *Person `json:"owner,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoteLink is one reference from a note to something else.
|
// NoteLink is one reference from a note to something else.
|
||||||
@@ -33,7 +57,7 @@ type NoteLink struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
||||||
created_at, updated_at, deleted_at`
|
created_at, updated_at, deleted_at, archived_at`
|
||||||
|
|
||||||
// The same columns, said with the table in front of them.
|
// The same columns, said with the table in front of them.
|
||||||
//
|
//
|
||||||
@@ -41,7 +65,8 @@ const noteColumns = `id, project_id, title, body, created_by, pinned,
|
|||||||
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
|
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
|
||||||
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
||||||
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
||||||
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at`
|
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at,
|
||||||
|
notes.archived_at`
|
||||||
|
|
||||||
// Reference syntax, and deliberately the same characters the quick-add parser
|
// Reference syntax, and deliberately the same characters the quick-add parser
|
||||||
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
||||||
@@ -77,6 +102,24 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
// it, and every caller gets the same answer because none of them can set it.
|
// it, and every caller gets the same answer because none of them can set it.
|
||||||
n.Title = firstLine(n.Body)
|
n.Title = firstLine(n.Body)
|
||||||
|
|
||||||
|
// Forseglet på vej ned, åbnet på vej op.
|
||||||
|
//
|
||||||
|
// Både titel og krop: titlen *er* første linje, så en forseglet krop ved siden
|
||||||
|
// af en læselig titel ville efterlade begyndelsen af hver eneste note i
|
||||||
|
// klartekst — og det er dér, folk skriver hvad noten handler om.
|
||||||
|
//
|
||||||
|
// `n` selv røres ikke. Kalderen har den note i hånden og læser videre i den
|
||||||
|
// bagefter; at forsegle den på plads ville give en note, hvis krop er
|
||||||
|
// ciffertekst, til den, der lige har gemt den.
|
||||||
|
title, err := db.sealValue(n.Title)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal note title: %w", err)
|
||||||
|
}
|
||||||
|
body, err := db.sealValue(n.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal note body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
var projectID any
|
var projectID any
|
||||||
if n.ProjectID != "" {
|
if n.ProjectID != "" {
|
||||||
@@ -96,7 +139,7 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
body = excluded.body,
|
body = excluded.body,
|
||||||
pinned = excluded.pinned,
|
pinned = excluded.pinned,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
|
n.ID, projectID, title, body, createdBy, n.Pinned,
|
||||||
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
|
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -170,7 +213,7 @@ func firstLine(body string) string {
|
|||||||
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
|
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
|
||||||
row := db.QueryRowContext(ctx,
|
row := db.QueryRowContext(ctx,
|
||||||
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
|
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
|
||||||
n, err := scanNote(row)
|
n, err := db.scanNote(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -207,8 +250,23 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
|||||||
// shared, and a list that dropped it at that moment would make sharing look like
|
// shared, and a list that dropped it at that moment would make sharing look like
|
||||||
// deleting — which is exactly how it read before this existed.
|
// deleting — which is exactly how it read before this existed.
|
||||||
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
return db.notesWhere(ctx, `
|
return db.allNotes(ctx, userID, false)
|
||||||
WHERE deleted_at IS NULL
|
}
|
||||||
|
|
||||||
|
// ArchivedNotes is the same list, the other way round: what has been put away.
|
||||||
|
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
|
return db.allNotes(ctx, userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
|
||||||
|
// Archived notes are left out of the ordinary list rather than mixed in and
|
||||||
|
// greyed: the point of putting something away is not seeing it.
|
||||||
|
state := "notes.archived_at IS NULL"
|
||||||
|
if archived {
|
||||||
|
state = "notes.archived_at IS NOT NULL"
|
||||||
|
}
|
||||||
|
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||||
|
WHERE `+state+` AND deleted_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
(project_id IS NULL AND created_by = ?)
|
(project_id IS NULL AND created_by = ?)
|
||||||
OR project_id IN (
|
OR project_id IN (
|
||||||
@@ -216,8 +274,9 @@ func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
|||||||
UNION
|
UNION
|
||||||
SELECT project_id FROM project_members WHERE user_id = ?
|
SELECT project_id FROM project_members WHERE user_id = ?
|
||||||
)
|
)
|
||||||
|
OR notes.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
|
||||||
)
|
)
|
||||||
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
|
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotesLinking answers the backwards question: what points at this thing.
|
// NotesLinking answers the backwards question: what points at this thing.
|
||||||
@@ -236,38 +295,104 @@ func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note,
|
|||||||
|
|
||||||
// SearchNotes matches title and body, in either spelling of a Danish word.
|
// SearchNotes matches title and body, in either spelling of a Danish word.
|
||||||
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
|
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
|
||||||
if strings.TrimSpace(query) == "" {
|
terms := SearchTerms(query)
|
||||||
|
if len(terms) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
expr := MatchExprOver(query, "title", "body")
|
|
||||||
if expr == "" {
|
// Hver note læses og åbnes.
|
||||||
return nil, nil
|
//
|
||||||
|
// Der er ikke noget indeks at spørge længere, og det er med vilje: `notes_fts`
|
||||||
|
// og den genererede `fold`-søjle var begge klartekstkopier af kroppen, så en
|
||||||
|
// forseglet krop ved siden af dem ville have været teater — en fil ville stadig
|
||||||
|
// røbe hver note. Se 0027.
|
||||||
|
//
|
||||||
|
// Målt frem for gættet: tolv hundrede noter à 3,3 kB koster **33–43 ms** pr.
|
||||||
|
// søgning. Fladen venter 250 ms efter sidste tastetryk, før den spørger, så det
|
||||||
|
// ligger inde i pausen og ses ikke.
|
||||||
|
//
|
||||||
|
// Tiden går ikke med at åbne konvolutterne — fire megabyte AES-GCM er få
|
||||||
|
// millisekunder — men med at folde og småskrive hver krop for hver søgning.
|
||||||
|
// Det er lineært, så regn med omtrent 350 ms ved ti tusind noter, og dér er det
|
||||||
|
// den forkerte form. Svaret er da et *nøglet* indeks: en HMAC pr. ord under
|
||||||
|
// samme nøgle. Bemærk hvad det koster, når det bliver aktuelt — et nøglet
|
||||||
|
// indeks kan slå et helt ord op og ikke andet, så præfiks- og delordssøgningen,
|
||||||
|
// der findes i dag, ville falde bort.
|
||||||
|
//
|
||||||
|
// Papirkurven springes over i SQL frem for i løkken: det er den ene begrænsning,
|
||||||
|
// der kan bruge et indeks, og den fjerner alt hvad der er slettet uden at åbne
|
||||||
|
// det.
|
||||||
|
notes, err := db.notesWhere(ctx, `WHERE deleted_at IS NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
// Ordered by how well it matched, not by when it was last touched.
|
|
||||||
//
|
type scored struct {
|
||||||
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
note Note
|
||||||
// useless at twelve hundred: every one of them was imported the same evening,
|
score int
|
||||||
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
}
|
||||||
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
found := make([]scored, 0, limit)
|
||||||
// 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.
|
for _, note := range notes {
|
||||||
//
|
score := scoreNote(note, terms)
|
||||||
// bm25 with the title weighted ten times the body. A word in the title is what
|
if score == 0 {
|
||||||
// the note is *about*; the same word once in a long body is a mention. `fold`
|
continue
|
||||||
// carries the same text transliterated, so it must weigh the same as the body
|
}
|
||||||
// or a Danish match would outrank an exact one.
|
found = append(found, scored{note: note, score: score})
|
||||||
//
|
}
|
||||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
|
||||||
// subquery throws it away.
|
// Bedste først, og ved lige stand den senest rørte. Uden det andet led er
|
||||||
return db.notesJoin(ctx, noteColumnsQualified, `
|
// rækkefølgen mellem to lige gode svar den, SQLite tilfældigvis gav dem, og den
|
||||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
// skifter mellem to søgninger på det samme.
|
||||||
WHERE notes.deleted_at IS NULL
|
sort.SliceStable(found, func(i, j int) bool {
|
||||||
AND notes_fts MATCH ?
|
if found[i].score != found[j].score {
|
||||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
return found[i].score > found[j].score
|
||||||
LIMIT ?`, expr, limit)
|
}
|
||||||
|
return found[i].note.UpdatedAt.After(found[j].note.UpdatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make([]Note, 0, min(limit, len(found)))
|
||||||
|
for i, f := range found {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, f.note)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreNote er hvor godt en note svarer på ordene, eller nul for slet ikke.
|
||||||
|
//
|
||||||
|
// Erstatter bm25, som forsvandt med indekset. Den er grovere, og det er værd at
|
||||||
|
// vide hvor: bm25 vægter et ord efter hvor sjældent det er i hele samlingen, og
|
||||||
|
// det kan ikke gøres uden en samling at tælle i. Det, der er beholdt, er den
|
||||||
|
// halvdel, der betød mest — **titlen vejer ti gange kroppen**. Et ord i titlen er
|
||||||
|
// hvad noten *handler om*; det samme ord én gang i en lang krop er en omtale.
|
||||||
|
//
|
||||||
|
// Alle ord skal findes. Det er den samme regel som før, hvor leddene var bundet
|
||||||
|
// med AND.
|
||||||
|
func scoreNote(note Note, terms []string) int {
|
||||||
|
title := FoldDanish(strings.ToLower(note.Title))
|
||||||
|
body := FoldDanish(strings.ToLower(note.Body))
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
for _, term := range terms {
|
||||||
|
needle := FoldDanish(strings.ToLower(term))
|
||||||
|
inTitle := strings.Count(title, needle)
|
||||||
|
inBody := strings.Count(body, needle)
|
||||||
|
if inTitle == 0 && inBody == 0 {
|
||||||
|
// Ét ord, der ikke findes, er et nej for hele noten.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der
|
||||||
|
// nævner alle ordene én gang. Det er den fejl, en ren optælling laver, og
|
||||||
|
// den er let at lave og svær at få øje på.
|
||||||
|
score += 10*min(inTitle, 3) + min(inBody, 5)
|
||||||
|
}
|
||||||
|
return score
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNoteTimes writes the dates a note had somewhere else.
|
// SetNoteTimes writes the dates a note had somewhere else.
|
||||||
@@ -301,6 +426,27 @@ func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNoteArchived puts a note away, or takes it back out.
|
||||||
|
//
|
||||||
|
// Separate from DeleteNote because they mean different things and the difference
|
||||||
|
// is the whole feature: the trash says "this was a mistake, and in thirty days it
|
||||||
|
// is gone", and the archive says "this is finished, and I want to keep it". A
|
||||||
|
// twelve-hundred-note import is mostly the second, and without somewhere to put
|
||||||
|
// them the list is the whole archive forever.
|
||||||
|
//
|
||||||
|
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
|
||||||
|
// sorted by when things were last written should not be reordered by somebody
|
||||||
|
// tidying up.
|
||||||
|
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
|
||||||
|
var at any
|
||||||
|
if archived {
|
||||||
|
at = time.Now().Unix()
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteNote puts it in the trash. Nothing here removes a row.
|
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||||
_, err := db.ExecContext(ctx,
|
_, err := db.ExecContext(ctx,
|
||||||
@@ -332,7 +478,7 @@ func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any)
|
|||||||
|
|
||||||
var out []Note
|
var out []Note
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
n, err := scanNote(rows)
|
n, err := db.scanNote(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -360,14 +506,18 @@ func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanNote(row scanner) (Note, error) {
|
// scanNote læser en række og åbner det, der er forseglet.
|
||||||
|
//
|
||||||
|
// En metode og ikke en funktion, fordi nøglen sidder på `db`. Det er den eneste
|
||||||
|
// grund; alt andet i den er den samme aflæsning som før.
|
||||||
|
func (db *DB) scanNote(row scanner) (Note, error) {
|
||||||
var n Note
|
var n Note
|
||||||
var projectID, createdBy sql.NullString
|
var projectID, createdBy sql.NullString
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
var deleted sql.NullInt64
|
var deleted, archived sql.NullInt64
|
||||||
|
|
||||||
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
||||||
&n.Pinned, &created, &updated, &deleted); err != nil {
|
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
n.ProjectID = projectID.String
|
n.ProjectID = projectID.String
|
||||||
@@ -378,5 +528,22 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
t := time.Unix(deleted.Int64, 0)
|
t := time.Unix(deleted.Int64, 0)
|
||||||
n.DeletedAt = &t
|
n.DeletedAt = &t
|
||||||
}
|
}
|
||||||
|
if archived.Valid {
|
||||||
|
t := time.Unix(archived.Int64, 0)
|
||||||
|
n.ArchivedAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Åbnet her, ét sted, så ingen kalder kan komme til at læse ciffertekst.
|
||||||
|
//
|
||||||
|
// En værdi skrevet før der var en nøgle kommer tilbage som sig selv — `v1:`
|
||||||
|
// findes netop for at kunne læse en blandet søjle — så en installation, der
|
||||||
|
// endnu ikke har fået sin bagudfyldning, virker imens.
|
||||||
|
for _, field := range []*string{&n.Title, &n.Body} {
|
||||||
|
plain, err := db.unsealValue(*field)
|
||||||
|
if err != nil {
|
||||||
|
return n, fmt.Errorf("open note %s: %w", n.ID, err)
|
||||||
|
}
|
||||||
|
*field = plain
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,3 +369,67 @@ func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
|||||||
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A word inside a word.
|
||||||
|
//
|
||||||
|
// Reported as "søgning har ikke det store held": a note holding
|
||||||
|
// `gamerule keepInventory true` could not be found by typing "keep inventory".
|
||||||
|
// The index is built of tokens, and `keepInventory` is one token — so the two
|
||||||
|
// words are nowhere, and the search says there is nothing while the note is right
|
||||||
|
// there.
|
||||||
|
//
|
||||||
|
// A decade of notes is full of these: commands, identifiers, file names. Every one
|
||||||
|
// of them is several words the index thinks is one.
|
||||||
|
func TestAWordInsideAWordIsStillFound(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.SaveNote(ctx, &Note{
|
||||||
|
CreatedBy: userID,
|
||||||
|
Body: "Minecraft Bedrock\n\ngamerule keepInventory true",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A second note, so a fallback that simply returns everything would be caught.
|
||||||
|
if err := db.SaveNote(ctx, &Note{
|
||||||
|
CreatedBy: userID,
|
||||||
|
Body: "Indkøb\n\nKaffe og filtre",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range []string{
|
||||||
|
"keep inventory", // the two words, which is how somebody looks for it
|
||||||
|
"inventory", // the tail on its own — no prefix query can reach this
|
||||||
|
"keepinventory", // written as one, which the index does hold
|
||||||
|
"KEEP INVENTORY", // and case is not a question anybody is asking
|
||||||
|
"vacuum", // not in either note
|
||||||
|
} {
|
||||||
|
found, err := db.SearchNotes(ctx, q, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", q, err)
|
||||||
|
}
|
||||||
|
want := 1
|
||||||
|
if q == "vacuum" {
|
||||||
|
want = 0
|
||||||
|
}
|
||||||
|
if len(found) != want {
|
||||||
|
t.Errorf("%q found %d notes, want %d", q, len(found), want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if want == 1 && found[0].Title != "Minecraft Bedrock" {
|
||||||
|
t.Errorf("%q found %q", q, found[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fast path is still the fast path: a word the index does hold must not be
|
||||||
|
// answered by the scan, or the ranking that was built for twelve hundred notes
|
||||||
|
// is quietly gone.
|
||||||
|
found, err := db.SearchNotes(ctx, "Bedrock", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("an ordinary word found %d notes", len(found))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,11 +391,15 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// On (user_id, scope), which is the key since 0025. It used to be on `user_id`
|
||||||
|
// alone and set `scope = excluded.scope` — so writing one scope did not add a
|
||||||
|
// row beside the others, it took theirs and renamed it. The `ai`, `gmail` and
|
||||||
|
// `calendar` settings each deleted the other two, and the write reported
|
||||||
|
// success every time.
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id, scope) DO UPDATE SET
|
||||||
scope = excluded.scope,
|
|
||||||
values_json = excluded.values_json,
|
values_json = excluded.values_json,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
userID, scope, string(raw), time.Now().Unix())
|
userID, scope, string(raw), time.Now().Unix())
|
||||||
|
|||||||
@@ -646,6 +646,35 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReorderSections writes the order of one project's sections, top to bottom.
|
||||||
|
//
|
||||||
|
// The whole list at once, the same as ReorderProjects and for the same reasons: a
|
||||||
|
// project has a handful of sections, so writing 0, 1, 2 … is one small
|
||||||
|
// transaction that cannot run out of precision, and a list is impossible to get
|
||||||
|
// into the half-ordered state a sequence of individual moves leaves behind when
|
||||||
|
// one of them fails.
|
||||||
|
//
|
||||||
|
// Scoped to the project rather than to a user. A section has no owner of its own —
|
||||||
|
// it belongs to a project, and whether the caller may rearrange it is a question
|
||||||
|
// about that project, which the handler has already asked. Ids belonging to some
|
||||||
|
// other project are skipped by the WHERE clause rather than refused, so a stale
|
||||||
|
// list from a client that has just moved a section elsewhere cannot rearrange a
|
||||||
|
// project the caller was never looking at.
|
||||||
|
func (db *DB) ReorderSections(ctx context.Context, projectID string, ids []string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE sections SET sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||||
|
float64(i), now, id, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SectionByName resolves a "/name" from quick add within one project.
|
// SectionByName resolves a "/name" from quick add within one project.
|
||||||
//
|
//
|
||||||
// Case-insensitive, and scoped to the project the task is landing in — a section
|
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ func MatchExprOver(query string, columns ...string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchTerms is the words a query asks about, capped the same way the MATCH
|
||||||
|
// expression is.
|
||||||
|
//
|
||||||
|
// Exported because two searches need the same list: the FTS expression is built
|
||||||
|
// from it, and so is the substring arm that finds a word living inside another
|
||||||
|
// word. Two tokenizers would be two answers to "what did they type".
|
||||||
|
func SearchTerms(query string) []string {
|
||||||
|
terms := tokenize(query)
|
||||||
|
if len(terms) > maxSearchTerms {
|
||||||
|
terms = terms[:maxSearchTerms]
|
||||||
|
}
|
||||||
|
return terms
|
||||||
|
}
|
||||||
|
|
||||||
// tokenize splits on everything that is not a letter or a digit. Splitting on
|
// tokenize splits on everything that is not a letter or a digit. Splitting on
|
||||||
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
|
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
|
||||||
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
|
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
|
||||||
|
|||||||
@@ -124,3 +124,272 @@ func sealedStore(t *testing.T) (*DB, string) {
|
|||||||
}
|
}
|
||||||
return db, user.ID
|
return db, user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Three integrations, one person, and none of them may delete the others.
|
||||||
|
//
|
||||||
|
// `user_settings` had `user_id` as its whole primary key and an upsert that
|
||||||
|
// rewrote `scope` on conflict, so every person had exactly one settings row and
|
||||||
|
// the last write won the lot: saving an AI provider deleted the Gmail connection,
|
||||||
|
// and connecting a calendar deleted the AI provider. The write reported success
|
||||||
|
// each time, which is why it was reported as "the AI settings are not saved"
|
||||||
|
// rather than as "connecting the calendar deleted them".
|
||||||
|
//
|
||||||
|
// Written against the store rather than the handlers, because the shape was the
|
||||||
|
// bug: every caller was correct on its own.
|
||||||
|
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
want := map[string]map[string]any{
|
||||||
|
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
|
||||||
|
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
|
||||||
|
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
|
||||||
|
}
|
||||||
|
// In this order on purpose: the AI settings are written first and read last,
|
||||||
|
// so a scope that steals the row is caught by the one written earliest.
|
||||||
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||||
|
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
|
||||||
|
t.Fatalf("save %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
||||||
|
got, err := db.UserSettings(ctx, userID, scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", scope, err)
|
||||||
|
}
|
||||||
|
for key, value := range want[scope] {
|
||||||
|
if got[key] != value {
|
||||||
|
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a second write to one scope updates it rather than adding another row —
|
||||||
|
// the upsert still has to upsert.
|
||||||
|
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var rows int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rows != 3 {
|
||||||
|
t.Errorf("the person has %d settings rows, want 3", rows)
|
||||||
|
}
|
||||||
|
back, err := db.UserSettings(ctx, userID, "ai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back["provider"] != "openai" {
|
||||||
|
t.Errorf("the second write did not land: %v", back["provider"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upgrade path, which is the one nobody runs: an existing database with a row
|
||||||
|
// in the old shape, opened by a binary that carries 0025.
|
||||||
|
//
|
||||||
|
// Every other test here starts empty and runs all the migrations in order, which
|
||||||
|
// exercises the migration's SQL but not its *situation*. The situation is a table
|
||||||
|
// that already holds somebody's settings, and the question is whether they come
|
||||||
|
// through it and whether a second scope can be written afterwards.
|
||||||
|
func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
user := &User{Email: "kristian@example.dk", Name: "Kristian"}
|
||||||
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wind the database back to how v0.33.3 left it: user_id as the whole primary
|
||||||
|
// key, and 0025 not yet applied.
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`PRAGMA foreign_keys = OFF`,
|
||||||
|
`CREATE TABLE us_old (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'general',
|
||||||
|
values_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)`,
|
||||||
|
`INSERT INTO us_old (user_id, scope, values_json, updated_at)
|
||||||
|
SELECT user_id, scope, values_json, updated_at FROM user_settings`,
|
||||||
|
`DROP TABLE user_settings`,
|
||||||
|
`ALTER TABLE us_old RENAME TO user_settings`,
|
||||||
|
`DELETE FROM schema_migrations WHERE name = '0025_user_settings_per_scope.sql'`,
|
||||||
|
`PRAGMA foreign_keys = ON`,
|
||||||
|
} {
|
||||||
|
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
t.Fatalf("rewind (%s): %v", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Gmail connection, written the way the old upsert wrote one.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
||||||
|
VALUES (?, 'gmail', '{"label":"Verdande"}', 0)`, user.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And now the new binary opens it.
|
||||||
|
db, err = Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the upgrade refused to open the database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
gmail, err := db.UserSettings(ctx, user.ID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gmail["label"] != "Verdande" {
|
||||||
|
t.Errorf("the Gmail settings did not survive the migration: %v", gmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The thing that was reported: set up the AI provider, and have it still be
|
||||||
|
// there afterwards.
|
||||||
|
if err := db.SetUserSettings(ctx, user.ID, "ai", map[string]any{
|
||||||
|
"provider": "anthropic", "model": "claude-sonnet-5",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("saving the AI provider after the upgrade: %v", err)
|
||||||
|
}
|
||||||
|
ai, err := db.UserSettings(ctx, user.ID, "ai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ai["provider"] != "anthropic" {
|
||||||
|
t.Errorf("the AI provider is %v after the upgrade, want anthropic", ai["provider"])
|
||||||
|
}
|
||||||
|
again, err := db.UserSettings(ctx, user.ID, "gmail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again["label"] != "Verdande" {
|
||||||
|
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer.
|
||||||
|
//
|
||||||
|
// Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes
|
||||||
|
// gennem fladen. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi,
|
||||||
|
// fordi der ligger sådan nogle i den installation, det her blev bygget til.
|
||||||
|
func TestANoteIsNotStoredInTheClear(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const secretLine = "gamerule keepInventory true"
|
||||||
|
n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine}
|
||||||
|
if err := db.SaveNote(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var title, body string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, secretLine) {
|
||||||
|
t.Errorf("kroppen står i klartekst i rækken: %s", body)
|
||||||
|
}
|
||||||
|
// Og titlen med. Titlen *er* første linje, så en forseglet krop ved siden af en
|
||||||
|
// læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.
|
||||||
|
if strings.Contains(title, "Minecraft") {
|
||||||
|
t.Errorf("titlen står i klartekst i rækken: %s", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kalderens egen note er urørt: den har den i hånden og læser videre i den.
|
||||||
|
if n.Body != "Minecraft Bedrock\n\n"+secretLine {
|
||||||
|
t.Errorf("gemningen ændrede kalderens note: %q", n.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Læst tilbage er den sig selv igen.
|
||||||
|
back, err := db.Note(ctx, n.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.Body != n.Body || back.Title != "Minecraft Bedrock" {
|
||||||
|
t.Errorf("noten kom anderledes tilbage: %+v", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et
|
||||||
|
// indeks, der var en klartekstkopi af den.
|
||||||
|
found, err := db.SearchNotes(ctx, "keep inventory", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades.
|
||||||
|
//
|
||||||
|
// Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver
|
||||||
|
// *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer
|
||||||
|
// igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle.
|
||||||
|
func TestOldNotesAreSealedOnStartup(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Skrevet uden om Go-laget, som en note fra før 0027 ligger.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO notes (id, title, body, created_by, created_at, updated_at)
|
||||||
|
VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`,
|
||||||
|
userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed, err := db.SealNotes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sealed != 1 {
|
||||||
|
t.Fatalf("%d noter blev lukket, want 1", sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body string
|
||||||
|
var updated int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "hemmeligt") {
|
||||||
|
t.Errorf("den gamle note ligger stadig åben: %s", body)
|
||||||
|
}
|
||||||
|
// `updated_at` er ikke rørt. Det betyder "hvornår skrev dette program noten
|
||||||
|
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||||
|
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville kaste den
|
||||||
|
// rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||||
|
if updated != 200 {
|
||||||
|
t.Errorf("bagudfyldningen flyttede updated_at til %d", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og den kan læses.
|
||||||
|
back, err := db.Note(ctx, "n-gammel")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back == nil || !strings.Contains(back.Body, "hemmeligt") {
|
||||||
|
t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit
|
||||||
|
// præfiks.
|
||||||
|
again, err := db.SealNotes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again != 0 {
|
||||||
|
t.Errorf("anden kørsel lukkede %d noter om igen", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One mail, one task — however many readers there are.
|
||||||
|
//
|
||||||
|
// Nothing could answer "has this already become a task?" before, so a second
|
||||||
|
// reader of the same mailbox made a second task. That is not hypothetical: a
|
||||||
|
// briefing run produced "Betal faktura fra Browns" twice in one pass, and a
|
||||||
|
// separate assistant reading the same inbox does the same thing every sweep.
|
||||||
|
func TestOneMailBecomesOneTask(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox := newProject(t, db, user.ID)
|
||||||
|
|
||||||
|
const thread = "gmail:thread:18f2cabc"
|
||||||
|
first := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
||||||
|
if err := db.CreateTask(ctx, first, nil); err != nil {
|
||||||
|
t.Fatalf("first task: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same thread again — a rerun, or somebody else's assistant.
|
||||||
|
second := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
||||||
|
err := db.CreateTask(ctx, second, nil)
|
||||||
|
if !errors.Is(err, ErrDuplicate) {
|
||||||
|
t.Fatalf("second task for the same thread: err = %v, want ErrDuplicate", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the question that makes the other agent safe.
|
||||||
|
id, err := db.TaskIDBySource(ctx, user.ID, thread)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id != first.ID {
|
||||||
|
t.Errorf("TaskIDBySource = %q, want %q", id, first.ID)
|
||||||
|
}
|
||||||
|
if id, err := db.TaskIDBySource(ctx, user.ID, "gmail:thread:aldrig-set"); err != nil || id != "" {
|
||||||
|
t.Errorf("an unseen thread answered %q, %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task somebody typed has no source, and there can be any number of those.
|
||||||
|
// The index is partial for exactly this reason — without the WHERE clause the
|
||||||
|
// second hand-written task in the database would be refused.
|
||||||
|
func TestTasksWithoutASourceDoNotCollide(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox := newProject(t, db, user.ID)
|
||||||
|
|
||||||
|
for i, name := range []string{"køb kaffe", "ring til Andreas", "hent pakken"} {
|
||||||
|
if err := db.CreateTask(ctx, &Task{ProjectID: inbox, Content: name, CreatedBy: user.ID}, nil); err != nil {
|
||||||
|
t.Fatalf("task %d (%s): %v", i, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id, err := db.TaskIDBySource(ctx, user.ID, ""); err != nil || id != "" {
|
||||||
|
t.Errorf("an empty source key answered %q, %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two people on one instance, each with their own mailbox. The same thread id
|
||||||
|
// reaching both of them is two tasks, correctly — the key is scoped to whoever
|
||||||
|
// the mail arrived for.
|
||||||
|
func TestTheSameThreadForTwoPeopleIsTwoTasks(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for _, email := range []string{"kristian@example.dk", "andreas@example.dk"} {
|
||||||
|
u := &User{Email: email, Name: email, PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox := newProject(t, db, u.ID)
|
||||||
|
task := &Task{ProjectID: inbox, Content: "Fælles tråd", CreatedBy: u.ID,
|
||||||
|
SourceKey: "gmail:thread:delt"}
|
||||||
|
if err := db.CreateTask(ctx, task, nil); err != nil {
|
||||||
|
t.Fatalf("%s: %v", email, err)
|
||||||
|
}
|
||||||
|
ids = append(ids, task.ID)
|
||||||
|
}
|
||||||
|
if ids[0] == ids[1] {
|
||||||
|
t.Error("the two people got the same task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project to hang the tasks on. The tests are about the source key, not about
|
||||||
|
// where a task lives.
|
||||||
|
func newProject(t *testing.T, db *DB, ownerID string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := &Project{OwnerID: ownerID, Name: "Indbakke"}
|
||||||
|
if err := db.CreateProject(context.Background(), p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
@@ -38,6 +38,81 @@ type DB struct {
|
|||||||
// misplaced key look like a corrupt disk.
|
// misplaced key look like a corrupt disk.
|
||||||
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
||||||
|
|
||||||
|
// SealNotes lukker de noter, der stadig ligger åbne.
|
||||||
|
//
|
||||||
|
// Kaldes én gang ved opstart, efter nøglen er sat. Uden den ville kroppene blive
|
||||||
|
// forseglet efterhånden som de bliver *skrevet* — sådan som instansens
|
||||||
|
// indstillinger gør det — og for et arkiv på tolv hundrede importerede noter, som
|
||||||
|
// ingen redigerer igen, betyder det aldrig. En bagudfyldning er den eneste måde,
|
||||||
|
// den her ændring rent faktisk beskytter noget på.
|
||||||
|
//
|
||||||
|
// Idempotent: en note, der allerede er forseglet, kendes på sit præfiks og røres
|
||||||
|
// ikke. Så den kan køre ved hver opstart uden at koste andet end et gennemløb af
|
||||||
|
// id'er og en længde.
|
||||||
|
//
|
||||||
|
// Skriver med SQL frem for gennem SaveNote. SaveNote udleder titlen af kroppen og
|
||||||
|
// bygger links om, og begge dele er allerede rigtige her — det, der skal ske, er
|
||||||
|
// alene, at de to søjler skal skiftes ud med sig selv, lukket.
|
||||||
|
func (db *DB) SealNotes(ctx context.Context) (int, error) {
|
||||||
|
if db.box == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id, title, body FROM notes`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
type open struct{ id, title, body string }
|
||||||
|
var pending []open
|
||||||
|
for rows.Next() {
|
||||||
|
var o open
|
||||||
|
if err := rows.Scan(&o.id, &o.title, &o.body); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if secret.IsSealed(o.body) && (o.title == "" || secret.IsSealed(o.title)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, o)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hele bunken i én transaktion. En halvt forseglet tabel er ikke i stykker —
|
||||||
|
// præfikset gør, at begge slags kan læses — men en afbrudt kørsel, der skal
|
||||||
|
// tages om, er lettere at ræsonnere om, når den enten skete eller ikke gjorde.
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
for _, o := range pending {
|
||||||
|
title, err := db.sealValue(o.title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := db.sealValue(o.body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// `updated_at` røres ikke. Det betyder "hvornår skrev dette program noten
|
||||||
|
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||||
|
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville
|
||||||
|
// kaste den rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE notes SET title = ?, body = ? WHERE id = ?`, title, body, o.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(pending), nil
|
||||||
|
}
|
||||||
|
|
||||||
// dsn is the connection string.
|
// dsn is the connection string.
|
||||||
//
|
//
|
||||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ type Task struct {
|
|||||||
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
|
// SourceKey names the thing out there this task was made from, namespaced:
|
||||||
|
// `gmail:thread:18f2c…`. Empty for a task somebody typed.
|
||||||
|
//
|
||||||
|
// It exists so that the second reader of a mailbox — another assistant, a
|
||||||
|
// rerun, a person — can ask "has this already become a task?" and get an
|
||||||
|
// answer. CreateTask returns ErrDuplicate rather than making a second one.
|
||||||
|
SourceKey string
|
||||||
SortOrder float64
|
SortOrder float64
|
||||||
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
||||||
// that there is something underneath it, and how much of it is left. Counted
|
// that there is something underneath it, and how much of it is left. Counted
|
||||||
@@ -166,15 +173,42 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
// labels are short, and their names are typed by the person searching for
|
// labels are short, and their names are typed by the person searching for
|
||||||
// them, so there is no spelling to be generous about. The diacritic
|
// them, so there is no spelling to be generous about. The diacritic
|
||||||
// folding that matters for task text does not earn a second index here.
|
// folding that matters for task text does not earn a second index here.
|
||||||
|
//
|
||||||
|
// The third arm is a word inside a word. An index is built of tokens, and
|
||||||
|
// `SpeedAdmin` is one of them — so "speed admin" asks for two words that are
|
||||||
|
// nowhere. A prefix query gets half of it (`speed*` matches, `admin*` cannot)
|
||||||
|
// and the two are joined with AND, so the half that works is thrown away.
|
||||||
|
//
|
||||||
|
// Notes got the same arm, and for the same reason. Having it in one of the
|
||||||
|
// two would be worse than having it in neither: "it finds it in my notes but
|
||||||
|
// not in my tasks" is a bug report waiting, and the answer to it would be
|
||||||
|
// this line.
|
||||||
|
//
|
||||||
|
// It scans, and it only ever runs when somebody typed something in the box —
|
||||||
|
// Today and Kommende do not set `Search`. Every term has to appear, so the
|
||||||
|
// arms are ANDed with each other while the whole thing is ORed with the two
|
||||||
|
// above.
|
||||||
|
inside := make([]string, 0, maxSearchTerms)
|
||||||
|
insideArgs := make([]any, 0, maxSearchTerms)
|
||||||
|
for _, term := range SearchTerms(f.Search) {
|
||||||
|
inside = append(inside, "t.fold LIKE ?")
|
||||||
|
insideArgs = append(insideArgs, "%"+FoldDanish(term)+"%")
|
||||||
|
}
|
||||||
|
text := ""
|
||||||
|
if len(inside) > 0 {
|
||||||
|
text = " OR (" + strings.Join(inside, " AND ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
where = append(where, `(
|
where = append(where, `(
|
||||||
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
||||||
OR t.id IN (
|
OR t.id IN (
|
||||||
SELECT tl.task_id FROM task_labels tl
|
SELECT tl.task_id FROM task_labels tl
|
||||||
JOIN labels l ON l.id = tl.label_id
|
JOIN labels l ON l.id = tl.label_id
|
||||||
WHERE lower(l.name) LIKE ?
|
WHERE lower(l.name) LIKE ?
|
||||||
)
|
)`+text+`
|
||||||
)`)
|
)`)
|
||||||
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
||||||
|
args = append(args, insideArgs...)
|
||||||
}
|
}
|
||||||
if f.FilterSQL != "" {
|
if f.FilterSQL != "" {
|
||||||
where = append(where, "("+f.FilterSQL+")")
|
where = append(where, "("+f.FilterSQL+")")
|
||||||
@@ -425,21 +459,48 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
|
|||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
|
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
|
||||||
priority, due_date, due_datetime, due_timezone, duration_min,
|
priority, due_date, due_datetime, due_timezone, duration_min,
|
||||||
recurrence_rule, assignee_id, created_by, sort_order,
|
recurrence_rule, assignee_id, created_by, source_key, sort_order,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
||||||
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
||||||
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
||||||
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
||||||
t.SortOrder, now.Unix(), now.Unix())
|
t.SourceKey, t.SortOrder, now.Unix(), now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// The one broken constraint that is not a bug: this source has already
|
||||||
|
// become a task. The caller skips it rather than making a duplicate,
|
||||||
|
// which is the entire reason the index exists.
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return ErrDuplicate
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
|
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskIDBySource answers "has this mail already become a task?" — the question
|
||||||
|
// nothing could answer before, and the one that stops two assistants reading the
|
||||||
|
// same inbox from making two of everything. Empty id and no error means no.
|
||||||
|
//
|
||||||
|
// Deleted tasks count. A task that was made from a mail and then thrown away is
|
||||||
|
// an answer — remaking it on the next sweep would be the same duplicate wearing
|
||||||
|
// a different hat.
|
||||||
|
func (db *DB) TaskIDBySource(ctx context.Context, userID, sourceKey string) (string, error) {
|
||||||
|
if sourceKey == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM tasks WHERE created_by = ? AND source_key = ?`,
|
||||||
|
userID, sourceKey).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
|
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
|
||||||
var max sql.NullFloat64
|
var max sql.NullFloat64
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ var (
|
|||||||
ErrNotFound = errors.New("store: not found")
|
ErrNotFound = errors.New("store: not found")
|
||||||
ErrEmailInUse = errors.New("store: that email address already has an account")
|
ErrEmailInUse = errors.New("store: that email address already has an account")
|
||||||
ErrInviteInvalid = errors.New("store: invite is not valid")
|
ErrInviteInvalid = errors.New("store: invite is not valid")
|
||||||
|
// ErrDuplicate er en unik nøgle, der blev brudt, oversat til noget en handler
|
||||||
|
// kan spørge om. Drivereren udstiller ikke sin egen fejltype brugbart, og en
|
||||||
|
// handler, der matcher på tekst, er en handler, der går i stykker den dag
|
||||||
|
// driveren omformulerer sig.
|
||||||
|
ErrDuplicate = errors.New("store: already exists")
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -200,9 +205,50 @@ func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections [
|
|||||||
// Person is the public half of a user: what somebody else is allowed to see when
|
// Person is the public half of a user: what somebody else is allowed to see when
|
||||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||||
type Person struct {
|
type Person struct {
|
||||||
ID string
|
ID string `json:"id"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
AvatarColor string
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonByID resolves one user to a name and colour, or ErrNotFound. Used to check
|
||||||
|
// that somebody a note is about to be shared with actually exists, without loading
|
||||||
|
// the whole account.
|
||||||
|
func (db *DB) PersonByID(ctx context.Context, id string) (Person, error) {
|
||||||
|
var p Person
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, name, avatar_color FROM users WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.AvatarColor)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Person{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersForSharing is everyone on the instance except the caller — the people a note
|
||||||
|
// can be handed to.
|
||||||
|
//
|
||||||
|
// Deliberately wider than ListPeople, which is the project collaborators. The whole
|
||||||
|
// point of sharing a note directly is to do it without first standing up a shared
|
||||||
|
// project, so the picker cannot be limited to people you already share a project
|
||||||
|
// with. An instance's accounts are a closed, invited set to begin with — there is
|
||||||
|
// no open signup — so listing them to each other is not disclosure, it is the
|
||||||
|
// address book the feature needs.
|
||||||
|
func (db *DB) UsersForSharing(ctx context.Context, meID string) ([]Person, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT id, name, avatar_color FROM users WHERE id <> ? ORDER BY lower(name)`, meID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
people := []Person{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Person
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
people = append(people, p)
|
||||||
|
}
|
||||||
|
return people, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeopleByIDs looks up several users at once.
|
// PeopleByIDs looks up several users at once.
|
||||||
@@ -461,6 +507,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
|
||||||
|
// fresh. It answers false when the step has already been spent — the same code, or
|
||||||
|
// an earlier one, being presented a second time.
|
||||||
|
//
|
||||||
|
// The guard is in the statement, not in a read-then-write: two logins racing with
|
||||||
|
// the same stolen code must not both find the old value and both proceed. The row
|
||||||
|
// moves forward only when the new step is strictly greater, so exactly one of them
|
||||||
|
// updates a row and the other sees nothing change.
|
||||||
|
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET totp_last_step = ? WHERE id = ?
|
||||||
|
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
|
||||||
|
step, userID, step)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InboxID returns the user's Inbox project.
|
// InboxID returns the user's Inbox project.
|
||||||
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
||||||
var id string
|
var id string
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ nav:
|
|||||||
- Using it:
|
- Using it:
|
||||||
- Signing in: signing-in.md
|
- Signing in: signing-in.md
|
||||||
- Quick add: quick-add.md
|
- Quick add: quick-add.md
|
||||||
|
- Notes: notes.md
|
||||||
- Filters: filters.md
|
- Filters: filters.md
|
||||||
- Importing from Todoist: import.md
|
- Importing from Todoist: import.md
|
||||||
- Connecting things:
|
- Connecting things:
|
||||||
- Calendar and CalDAV: caldav.md
|
- Calendar: calendar.md
|
||||||
|
- Feeds and CalDAV: caldav.md
|
||||||
- Mail to task: mail.md
|
- Mail to task: mail.md
|
||||||
- Mailboxes: mailboxes.md
|
- Mailboxes: mailboxes.md
|
||||||
- Claude (MCP): mcp.md
|
- Claude (MCP): mcp.md
|
||||||
|
|||||||
@@ -15,12 +15,37 @@ gameskill:
|
|||||||
# this: a manifest with new fields and an unchanged version is a manifest the
|
# this: a manifest with new fields and an unchanged version is a manifest the
|
||||||
# panel has no reason to re-read, so the fields never appear and the failure is
|
# panel has no reason to re-read, so the fields never appear and the failure is
|
||||||
# silent — the server's settings page simply does not offer them.
|
# silent — the server's settings page simply does not offer them.
|
||||||
version: 4
|
# Bumpet til 6: v5 flyttede imaget til vores egen Gitea, og det virkede ikke.
|
||||||
|
# En rune opdaterer ikke sig selv — panelet skriver rune-rækken fra en
|
||||||
|
# UI-handling, så ændringen her kræver **Runes → verdande → Update**, og
|
||||||
|
# katalog-listen caches ti minutter, så to forsøg lige efter hinanden kan begge
|
||||||
|
# give den gamle version.
|
||||||
|
version: 6
|
||||||
icon: "app"
|
icon: "app"
|
||||||
|
|
||||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||||
# image already declares as its ENTRYPOINT, and startup.exec passes it argv
|
# image already declares as its ENTRYPOINT, and startup.exec passes it argv
|
||||||
# directly rather than through /bin/sh, which does not exist in there.
|
# directly rather than through /bin/sh, which does not exist in there.
|
||||||
|
# GHCR, ikke vores egen Gitea. v5 prøvede det sidste og tog instansen ned.
|
||||||
|
#
|
||||||
|
# Panelets `PullImage` sætter aldrig `RegistryAuth`, så registret SKAL kunne
|
||||||
|
# læses uden legitimation. GHCR kan: dens token-endepunkt svarer 200 og udsteder
|
||||||
|
# en pull-token til hvem som helst. Gitea på gitea.nolimit.dk kan ikke —
|
||||||
|
# `/v2/token?scope=repository:kw/verdande:pull` svarer 401, også efter at repoet
|
||||||
|
# blev gjort offentligt. Instansen kræver login på alt, registret inklusive, og
|
||||||
|
# det er en indstilling for hele instansen frem for for denne pakke.
|
||||||
|
#
|
||||||
|
# Sådan ser det ud, når det går galt, for det tog en time at finde: panelet
|
||||||
|
# river containeren ned for at anvende runen, pullet fejler, `pullImageRetry`
|
||||||
|
# kasserer fejlen, og `ContainerCreate` slår derefter op LOKALT og siger
|
||||||
|
# `No such image`. Serveren står som "running" uden container_id, og udadtil er
|
||||||
|
# det en 502. Fejlen nævner aldrig, at det var et login, der manglede.
|
||||||
|
#
|
||||||
|
# Sådan afgør man det, uden at blive narret: spørg registrets token-endepunkt
|
||||||
|
# med `curl` og se efter en 200 med et token i. Test det ALDRIG med
|
||||||
|
# `docker --config <tom-mappe> pull` — en tom config stopper ikke maskinens
|
||||||
|
# credential-helper, så pullet bliver autentificeret uden at se sådan ud, og
|
||||||
|
# svaret er præcis det modsatte af sandheden.
|
||||||
docker:
|
docker:
|
||||||
image: "ghcr.io/kristianwind/verdande:latest"
|
image: "ghcr.io/kristianwind/verdande:latest"
|
||||||
data_path: /data
|
data_path: /data
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
<title>verdande — self-hosted tasks, shared</title>
|
<title>verdande — self-hosted tasks, shared</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
|
content="Self-hosted tasks, notes and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, a CalDAV server, notes in Markdown, passkeys, and an MCP connector for Claude."
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#111312" />
|
<meta name="theme-color" content="#111312" />
|
||||||
|
|
||||||
<meta property="og:title" content="verdande" />
|
<meta property="og:title" content="verdande" />
|
||||||
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
|
<meta property="og:description" content="Self-hosted tasks, notes and projects, shared. One binary, SQLite inside it." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="screenshots/today.webp" />
|
<meta property="og:image" content="screenshots/today.webp" />
|
||||||
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
||||||
@@ -76,10 +76,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: fixed;
|
/* In the bar now rather than floating over the corner. Fixed, it sat
|
||||||
top: 1rem;
|
on top of whatever happened to scroll under it — on a narrow window
|
||||||
right: 1rem;
|
that was the first screenshot's own corner. */
|
||||||
z-index: 10;
|
flex: none;
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -141,10 +141,86 @@
|
|||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- the bar that stays -------------------------------------------
|
||||||
|
|
||||||
|
A page this long had its only way out at the very top: somebody who
|
||||||
|
read as far as "What it will not do" had to scroll back past nine
|
||||||
|
screenshots to reach the documentation. The bar is the way out from
|
||||||
|
wherever you are.
|
||||||
|
|
||||||
|
`sticky` and not `fixed`: sticky keeps its space in the flow, so
|
||||||
|
nothing below has to be pushed down by a number that then has to be
|
||||||
|
kept in step with the bar's own height.
|
||||||
|
|
||||||
|
Full width, with the wrap *inside* it. Put the other way round, the
|
||||||
|
background would end where the column ends and the page would scroll
|
||||||
|
past it on both sides. */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--ground);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-brand .rune {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links a {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-links a:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The three in-page links go first on a narrow screen: they are the ones
|
||||||
|
a phone can reach by scrolling anyway. Documentation and Source cannot
|
||||||
|
be reached any other way, so they stay. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bar-links .inpage {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Or the bar covers the heading you just jumped to. */
|
||||||
|
:target {
|
||||||
|
scroll-margin-top: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- hero --- */
|
/* --- hero --- */
|
||||||
|
|
||||||
header {
|
.hero {
|
||||||
padding: 6rem 0 4rem;
|
padding: 5rem 0 3.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +248,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.norn {
|
.norn {
|
||||||
margin: 2rem auto 0;
|
margin: 0 auto 1.75rem;
|
||||||
max-width: 34rem;
|
max-width: 34rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
@@ -289,44 +365,104 @@
|
|||||||
|
|
||||||
/* --- features --- */
|
/* --- features --- */
|
||||||
|
|
||||||
|
/* A hairline between sections rather than under every heading: the page
|
||||||
|
is one column, and the line says where one subject stops. */
|
||||||
section {
|
section {
|
||||||
padding: 3rem 0;
|
padding: 3.5rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 2rem;
|
margin: 0 0 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A short accent rule in front of every h2. The sections had nothing to
|
||||||
|
mark them but their own words, and at a glance the page read as one
|
||||||
|
long run of paragraphs. */
|
||||||
|
h2::before {
|
||||||
|
content: '';
|
||||||
|
flex: none;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform: translateY(-0.35em);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three groups rather than eighteen cards in a row. Grouped, the list is
|
||||||
|
read as three answers — what it does, what it talks to, what it
|
||||||
|
promises — and any of the three can be skipped. */
|
||||||
|
.group-label {
|
||||||
|
margin: 2.5rem 0 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 560;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards with their own edges, not cells in one table.
|
||||||
|
The table shared a hairline grid, which meant the last row had to be
|
||||||
|
full or it was a hole in the drawing — and it was a hole, for as long
|
||||||
|
as there were ten of them. A card that stands alone on its row is a
|
||||||
|
card; an empty cell is a mistake. */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||||
gap: 1px;
|
gap: 0.875rem;
|
||||||
background: var(--line);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
background: var(--ground);
|
background: var(--surface);
|
||||||
padding: 1.5rem;
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.125rem 1.25rem 1.25rem;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell h3 {
|
.cell h3 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.4375rem;
|
||||||
font-size: 1rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell p {
|
.cell p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.875rem;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A card that has something to admit rather than something to offer.
|
||||||
|
It is not a footnote and it is not fine print: it is the same size as
|
||||||
|
everything else, and it says the same thing the README says. */
|
||||||
|
.cell .caveat {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- install --- */
|
/* --- install --- */
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
@@ -374,6 +510,168 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Four facts under the buttons, in the place where a landing page usually
|
||||||
|
puts logos it has not earned. They are the four questions somebody
|
||||||
|
self-hosting asks before anything else. */
|
||||||
|
ul.facts {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.facts li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The separator belongs between two facts, not after the last one — so it
|
||||||
|
is drawn by the item that follows it. */
|
||||||
|
ul.facts li + li::before {
|
||||||
|
content: '';
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a phone the row wraps, and a separator drawn by the item that follows
|
||||||
|
it lands at the *start* of the new line — a dot with nothing before it.
|
||||||
|
No separators there; the wrap is the separation. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
ul.facts {
|
||||||
|
gap: 0.375rem 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.facts li + li::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two ways to run it, side by side, because they are alternatives rather
|
||||||
|
than steps. Stacked, the second read as what you do after the first. */
|
||||||
|
.panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunk rather than raised: inside a card, a block in --surface would be
|
||||||
|
the same colour as the card it sits in. */
|
||||||
|
.panel pre {
|
||||||
|
background: var(--ground);
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Versionsnummeret i sin egen søjle, så øjet kan løbe ned ad dem uden at
|
||||||
|
læse sætningerne. `ch` frem for pixels: søjlen er lige så bred som de
|
||||||
|
tal, der står i den, og den følger med, hvis skriften skifter. */
|
||||||
|
ul.lately {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.lately li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5ch 1fr;
|
||||||
|
gap: 0 1rem;
|
||||||
|
padding: 0.875rem 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.lately .ver {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
/* Tallet står på linje med den første tekstlinje og ikke over den. */
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
ul.lately li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Risikoen står i kort, ikke i småt.
|
||||||
|
Et forbehold sat med mindre skrift end resten er et forbehold, der er
|
||||||
|
skrevet for at kunne peges på bagefter — og det er den ene ting, denne
|
||||||
|
side ikke skal gøre. Samme størrelse som alt andet. */
|
||||||
|
.risk {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk strong {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The non-goals were one paragraph of seven sentences, which is the shape
|
||||||
|
nobody reads. Each is its own line now, and they can be counted. */
|
||||||
|
ul.nogo {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: 0.5rem 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.nogo li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.nogo li::before {
|
||||||
|
content: '—';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.demo-note {
|
.demo-note {
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
@@ -524,27 +822,38 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
<div class="topbar">
|
||||||
<span aria-hidden="true">◐</span>
|
<div class="wrap bar">
|
||||||
</button>
|
<a class="bar-brand" href="#top">
|
||||||
<div class="wrap">
|
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||||
<header>
|
verdande
|
||||||
|
</a>
|
||||||
|
<nav class="bar-links" aria-label="Sections">
|
||||||
|
<a class="inpage" href="#screens">Screens</a>
|
||||||
|
<a class="inpage" href="#features">Features</a>
|
||||||
|
<a class="inpage" href="#running">Running it</a>
|
||||||
|
<a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
|
<a href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
|
<button class="theme-toggle" type="button" aria-label="Switch between light and dark" title="Light or dark">
|
||||||
|
<span aria-hidden="true">◐</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wrap" id="top">
|
||||||
|
<header class="hero">
|
||||||
<span class="mark" aria-hidden="true">ᚹ</span>
|
<span class="mark" aria-hidden="true">ᚹ</span>
|
||||||
<h1>verdande</h1>
|
<h1>verdande</h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Self-hosted tasks and projects, shared with the people you share the work with.
|
Self-hosted tasks, notes and projects, shared with the people you share the work
|
||||||
One binary, SQLite inside it.
|
with. One binary, SQLite inside it.
|
||||||
</p>
|
</p>
|
||||||
<p class="rune-note">
|
<p class="rune-note">
|
||||||
Built to be deployed as a <strong>Rune</strong> in
|
Built to be deployed as a <strong>Rune</strong> in
|
||||||
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
|
<a href="https://github.com/kristianwind/yggdrasil">Yggdrasil Panel</a> — upload
|
||||||
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
|
the manifest, fill in two fields, press start. It also runs anywhere Docker does.
|
||||||
</p>
|
</p>
|
||||||
<p class="norn">
|
|
||||||
Verdande is one of the three Norns — the one who spins <em>that which is
|
|
||||||
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
|
||||||
in the middle of. Which is what a to-do list is.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||||
@@ -553,6 +862,14 @@
|
|||||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul class="facts">
|
||||||
|
<li>One Go binary</li>
|
||||||
|
<li>SQLite inside it</li>
|
||||||
|
<li>amd64 and arm64</li>
|
||||||
|
<li>No account anywhere but yours</li>
|
||||||
|
<li>MIT</li>
|
||||||
|
</ul>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
<!-- No screenshot: the quick-add parser is the thing worth showing, and it
|
||||||
@@ -584,7 +901,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section id="screens">
|
||||||
<h2>What it looks like</h2>
|
<h2>What it looks like</h2>
|
||||||
<figure class="shot only-dark">
|
<figure class="shot only-dark">
|
||||||
<img
|
<img
|
||||||
@@ -609,6 +926,26 @@
|
|||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<div class="shots">
|
<div class="shots">
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/calendar-light.webp"
|
||||||
|
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-shared-light.webp"
|
||||||
|
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
|
||||||
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/board.webp"
|
src="screenshots/board.webp"
|
||||||
@@ -669,11 +1006,43 @@
|
|||||||
/>
|
/>
|
||||||
<figcaption>What has been done on the server, across every project.</figcaption>
|
<figcaption>What has been done on the server, across every project.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/delegated.webp"
|
||||||
|
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Waiting on others — grouped by who has it.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-source.webp"
|
||||||
|
alt="A note with the source view open: the same note as Markdown, with headings, a list, a quotation, a wiki link and a fenced code block."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>The same note as Markdown. What is on disk is what you see.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/settings.webp"
|
||||||
|
alt="The integrations settings page: connecting Gmail with an OAuth client, and below it the calendar feed address."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="features">
|
||||||
<h2>What it does</h2>
|
<h2>What it does</h2>
|
||||||
|
|
||||||
|
<p class="group-label">Doing the work</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Reads what you type</h3>
|
<h3>Reads what you type</h3>
|
||||||
@@ -683,20 +1052,71 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Shared, properly</h3>
|
<h3>Nothing to remember</h3>
|
||||||
<p>
|
<p>
|
||||||
Owner, editor and viewer roles. People outside your instance join by
|
Start typing anywhere and you are writing a task, with the letter you
|
||||||
invite link. Changes appear live.
|
pressed already in the field. <code>⌘K</code> goes anywhere else.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Search that reads Danish</h3>
|
||||||
|
<p>
|
||||||
|
Type <code>gron</code> and find <code>grøn</code>. Unicode does not
|
||||||
|
consider <code>ø</code> an accented <code>o</code>, so a search that
|
||||||
|
leans on that alone is broken for the people this was built for.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Repeats, and says so in words</h3>
|
||||||
|
<p>
|
||||||
|
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||||
|
screen. Reminders are absolute or relative, and go out to the devices
|
||||||
|
you have allowed them on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Notes, beside the work</h3>
|
<h3>Notes, beside the work</h3>
|
||||||
<p>
|
<p>
|
||||||
Rich text to write in, Markdown on disk. File a note in a project and
|
Rich text to write in, Markdown on disk — and a source view to see it.
|
||||||
everybody in it can read it; <code>#project</code> and
|
Images are pasted or dragged in. <code>#project</code> and
|
||||||
<code>[[another note]]</code> are links, and a task shows what has been
|
<code>[[another note]]</code> are links, and a task shows what has been
|
||||||
written about it. A zip of Markdown goes in and comes back out the same
|
written about it. Archive what is done with it; the bin is for mistakes.
|
||||||
shape, so nothing is trapped here.
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Share a note, or a page</h3>
|
||||||
|
<p>
|
||||||
|
Hand a single note to a person — to read or to edit — without standing up
|
||||||
|
a project for it. Notes other people share with you gather under
|
||||||
|
<em>Shared with me</em>, each marked with whose it is. File it in a project
|
||||||
|
instead and everyone who can read the project reads the note.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>How long it takes</h3>
|
||||||
|
<p>
|
||||||
|
A task can carry a length, and the calendar draws it to scale. Drag the
|
||||||
|
foot of a task to make it longer or shorter, or drag its body to another
|
||||||
|
day or time — the length comes along.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Waiting on others</h3>
|
||||||
|
<p>
|
||||||
|
What you have handed to somebody else, on its own page, grouped under
|
||||||
|
the person who has it. The thing a shared list stops being able to tell
|
||||||
|
you the moment it is shared.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="group-label">Connected to what you already use</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Shared, properly</h3>
|
||||||
|
<p>
|
||||||
|
Owner, editor and viewer roles. People outside your instance join by
|
||||||
|
invite link. Changes appear live.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -706,6 +1126,27 @@
|
|||||||
here. Not a read-only feed pretending.
|
here. Not a read-only feed pretending.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>A calendar of its own</h3>
|
||||||
|
<p>
|
||||||
|
A month or a week of what is due, and a secret ICS address to subscribe
|
||||||
|
to from Apple Calendar, Google or Thunderbird. A Google calendar can be
|
||||||
|
laid over the top, read-only.
|
||||||
|
</p>
|
||||||
|
<p class="caveat">
|
||||||
|
The Google half is written to Google's documentation and has not yet
|
||||||
|
been run against Google. The feed and CalDAV have.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Your mail, as an inbox</h3>
|
||||||
|
<p>
|
||||||
|
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
||||||
|
it becomes a task. Forward one to your own address instead and the
|
||||||
|
subject is parsed like anything else you type. One way: unstarring it
|
||||||
|
later does nothing to the task you made.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Claude, connected</h3>
|
<h3>Claude, connected</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -714,35 +1155,39 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Your mail, as an inbox</h3>
|
<h3>AI only if you ask for it</h3>
|
||||||
<p>
|
<p>
|
||||||
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
|
Off by default, your own key, and your choice of Anthropic, OpenAI,
|
||||||
a task. Forward one to your own address instead and the subject is
|
Google or something local. Nothing leaves the server until you have
|
||||||
parsed like anything else you type. One way: unstarring it later does
|
filled that in, and everything works without it.
|
||||||
nothing to the task you made.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="group-label">Yours, and checkable</p>
|
||||||
|
<div class="grid">
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Nothing to remember</h3>
|
<h3>More than one way in</h3>
|
||||||
<p>
|
<p>
|
||||||
Start typing anywhere and you are writing a task, with the letter you
|
Passkeys, or a password hashed with Argon2id and a second factor with
|
||||||
pressed already in the field. Sign in with a passkey and there is no
|
recovery codes. Every device you are signed in on is listed, and any of
|
||||||
password to forget either.
|
them can be signed out from here.
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="cell">
|
|
||||||
<h3>Yours to leave</h3>
|
|
||||||
<p>
|
|
||||||
Import from Todoist, export back to it, or take everything as JSON. The
|
|
||||||
round trip is tested, not promised.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>Keys stay behind</h3>
|
<h3>Keys stay behind</h3>
|
||||||
<p>
|
<p>
|
||||||
Mail tokens and mailbox passwords are encrypted in the database, and the
|
Mail tokens, mailbox passwords, second-factor secrets and AI keys are
|
||||||
key lives in a file beside it — not inside it. So a backup you download,
|
encrypted in the database, and the key lives in a file beside it — not
|
||||||
or one that ends up somewhere it should not, does not open your mail.
|
inside it. So a backup you download, or one that ends up somewhere it
|
||||||
|
should not, does not open your mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Nobody signs themselves up</h3>
|
||||||
|
<p>
|
||||||
|
The first account is the administrator, and after that people arrive by
|
||||||
|
invitation. An address on the internet is not an open door.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -753,50 +1198,157 @@
|
|||||||
instance's record, not the person's.
|
instance's record, not the person's.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Yours to leave</h3>
|
||||||
|
<p>
|
||||||
|
Import from Todoist, export back to it, take everything as JSON, or
|
||||||
|
hand the notes a zip of Markdown and get the same zip back out. The
|
||||||
|
round trip is tested, not promised.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>It counts itself, out loud</h3>
|
||||||
|
<p>
|
||||||
|
Once a day an instance says it exists — a random id, the version, and
|
||||||
|
nothing else. No tasks, no names, no addresses. It is on to begin with,
|
||||||
|
the settings page prints the exact payload, and one switch stops it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="recently">
|
||||||
|
<h2>Lately</h2>
|
||||||
|
<p class="muted">
|
||||||
|
A landing page can say a project is alive; a list of what landed says it
|
||||||
|
better. This is not a changelog — every release has notes, and they are on
|
||||||
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
|
</p>
|
||||||
|
<ul class="lately">
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.34</span>
|
||||||
|
Sections can be dragged into the order the work actually happens in. The
|
||||||
|
sidebar keeps its head still while the projects scroll.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.33</span>
|
||||||
|
Notes grew an archive, selection of many at once, pictures you can paste
|
||||||
|
or drag in, a source view, and a copy button on code blocks. Search
|
||||||
|
stopped answering by date and started answering by relevance.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.32</span>
|
||||||
|
A calendar of its own as a menu item, with a read-only Google calendar
|
||||||
|
that can be laid over it.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.31</span>
|
||||||
|
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
|
||||||
|
task — not only Gmail.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.30</span>
|
||||||
|
An installation count that says exactly what it sends, and can be turned
|
||||||
|
off in one click.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="running">
|
||||||
<h2>Running it</h2>
|
<h2>Running it</h2>
|
||||||
|
|
||||||
<h3 class="sub">As a Rune in Yggdrasil Panel</h3>
|
<div class="panels">
|
||||||
<p class="muted">
|
<div class="panel">
|
||||||
This is what it was built for. <strong>Runes → Carve a rune</strong>, upload
|
<h3>As a Rune in Yggdrasil Panel</h3>
|
||||||
<code>rune/verdande.yaml</code>, create a server from it, set the public address
|
<p>
|
||||||
and your mail server, and start it. No port forwarding — give it a subdomain and
|
This is what it was built for. <strong>Runes → Carve a rune</strong>,
|
||||||
the panel routes it through your Cloudflare Tunnel.
|
upload <code>rune/verdande.yaml</code>, create a server from it, set the
|
||||||
</p>
|
public address and your mail server, and start it. No port forwarding —
|
||||||
|
give it a subdomain and the panel routes it through your Cloudflare
|
||||||
<h3 class="sub">Or with Docker, anywhere</h3>
|
Tunnel.
|
||||||
<pre><code>docker run -d --name verdande \
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Or with Docker, anywhere</h3>
|
||||||
|
<p>Open the address afterwards and create the first account. That one is the administrator.</p>
|
||||||
|
<pre><code>docker run -d --name verdande \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-v verdande-data:/data \
|
-v verdande-data:/data \
|
||||||
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
-e VERDANDE_BASE_URL=https://todo.example.dk \
|
||||||
ghcr.io/kristianwind/verdande:latest</code></pre>
|
ghcr.io/kristianwind/verdande:latest</code></pre>
|
||||||
<p class="muted" style="margin-top: 1rem">
|
</div>
|
||||||
Open the address and create the first account. That one is the administrator.
|
</div>
|
||||||
</p>
|
|
||||||
<ul class="plain">
|
<ul class="plain">
|
||||||
<li>No database to provision — SQLite is inside the binary.</li>
|
<li>No database to provision — SQLite is inside the binary.</li>
|
||||||
<li>Distroless image: no shell, no package manager, one static file.</li>
|
<li>Distroless image: no shell, no package manager, one static file.</li>
|
||||||
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
<li>amd64 and arm64, so a Raspberry Pi is fine.</li>
|
||||||
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
<li>Nightly backups, fourteen kept, written with <code>VACUUM INTO</code>.</li>
|
||||||
|
<li>Twenty-four migrations, run on start. Upgrading is pulling the new image.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="limits">
|
||||||
<h2>What it will not do</h2>
|
<h2>What it will not do</h2>
|
||||||
<p class="muted">
|
<ul class="nogo">
|
||||||
No karma or points. No team workspaces. No native apps. No mail integration
|
<li>No karma, streaks or points.</li>
|
||||||
beyond Gmail and the forwarding address. No languages beyond Danish and English.
|
<li>No team workspaces — projects and the people in them are the whole model.</li>
|
||||||
And no sync protocol of its own — CalDAV and the API <em>are</em> the sync.
|
<li>No native apps. It is a PWA, and it installs on a phone.</li>
|
||||||
</p>
|
<li>No Outlook or Exchange: Microsoft has closed IMAP to passwords and wants its own flow.</li>
|
||||||
|
<li>No languages beyond Danish and English.</li>
|
||||||
|
<li>No sync protocol of its own — CalDAV and the API <em>are</em> the sync.</li>
|
||||||
|
<li>No hosted version. There is nowhere to sign up, and that is the point.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="risk">
|
||||||
|
<h2>At your own risk</h2>
|
||||||
|
<div class="risk">
|
||||||
|
<p>
|
||||||
|
verdande is <strong>MIT licensed and provided as is, without warranty of
|
||||||
|
any kind</strong>. That sentence is in every MIT licence and it is usually
|
||||||
|
skipped, so here it is in the open: if you run this, you run it at your
|
||||||
|
own risk, and nobody is on the hook if it loses your work.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It is one person's project, at version 0.x, and 0.x means the shape can
|
||||||
|
still change. <strong>You host it, so the data and the backups are
|
||||||
|
yours.</strong> It writes a backup every night and keeps fourteen — and a
|
||||||
|
backup nobody has ever restored is not yet a backup. Restore one before
|
||||||
|
you need to.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Security is worked on rather than claimed. There have been two full
|
||||||
|
reviews of the code, every finding is written down and closed, and what is
|
||||||
|
protected and what is not is spelled out in
|
||||||
|
<a href="https://github.com/kristianwind/verdande/blob/main/SECURITY.md">SECURITY.md</a>
|
||||||
|
— including the parts that are a deliberate trade-off. <strong>Nobody
|
||||||
|
outside the project has audited it.</strong> The last review found two ways
|
||||||
|
to reach something you should not and closed both; a third review will find
|
||||||
|
more, which is the point of having one.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It is built to sit behind something — a tunnel, a reverse proxy, a VPN —
|
||||||
|
put there by somebody who knows what they exposed. Found a hole? Mail it
|
||||||
|
rather than opening an issue; the address is in SECURITY.md and you get an
|
||||||
|
answer within a week.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
|
||||||
|
where it stood between somebody arriving and the buttons they came
|
||||||
|
for — and it is a closing thought, not an opening one. -->
|
||||||
|
<p class="norn">
|
||||||
|
Verdande is one of the three Norns — the one who spins <em>that which is
|
||||||
|
becoming</em>. Not what has been, and not what is fated to be: the thing you are
|
||||||
|
in the middle of. Which is what a to-do list is.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
MIT licensed ·
|
MIT licensed ·
|
||||||
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
<a href="https://github.com/kristianwind/verdande">github.com/kristianwind/verdande</a>
|
||||||
|
· <a href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Built to run as a Rune in
|
Built to run as a Rune in
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |