Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68d6a8c15 | ||
|
|
ff0447317b | ||
|
|
a2f6e9bda3 | ||
|
|
1bbd36c79b | ||
|
|
54359f8d68 | ||
|
|
f400705bb7 | ||
|
|
0dc4c2f9b8 | ||
|
|
7f09b35db5 | ||
|
|
0f4048d8f2 | ||
|
|
9b21b3eac5 | ||
|
|
a39f655565 | ||
|
|
7e981354c2 | ||
|
|
1f0a43ac90 | ||
|
|
61f8887319 | ||
|
|
72637a153a | ||
|
|
f06a25b43e | ||
|
|
6cf184caa6 | ||
|
|
3c14906db2 | ||
|
|
70d7398d5a | ||
|
|
1cabbb7a78 | ||
|
|
5eb689e4c2 | ||
|
|
e931ddc313 | ||
|
|
2210dc082a | ||
|
|
9fa47822c1 | ||
|
|
366bed3a15 | ||
|
|
07fc170b91 | ||
|
|
8139003b35 | ||
|
|
96e1fa251b | ||
|
|
40102d6038 | ||
|
|
0cf5c8c281 | ||
|
|
445d9de57a | ||
|
|
d5ad459eba | ||
|
|
c297d256e4 | ||
|
|
d063425cae | ||
|
|
0764fbd566 | ||
|
|
b041b32aa2 | ||
|
|
d8f5f6e236 | ||
|
|
61634a6bb5 | ||
|
|
8ee9fbedfb | ||
|
|
ab77024ec9 | ||
|
|
48509e3073 | ||
|
|
916a0c0224 | ||
|
|
ff75302cf8 | ||
|
|
08d703e3af | ||
|
|
d4784fd174 | ||
|
|
094ea5cdd8 | ||
|
|
f60758b770 | ||
|
|
9d390d021f | ||
|
|
18db79c063 | ||
|
|
1612f53966 | ||
|
|
4d5f1a56b1 | ||
|
|
274e392708 | ||
|
|
84ca506961 | ||
|
|
6db61b0cc7 | ||
|
|
57689ddfbb | ||
|
|
7770ba9844 | ||
|
|
55b8e438ad | ||
|
|
1607a22f0c | ||
|
|
8a5dfef41a | ||
|
|
1a610eaac4 | ||
|
|
c2d4ce0179 | ||
|
|
a1b8cc6ddc | ||
|
|
8a2c46f5ea | ||
|
|
8d42f34065 | ||
|
|
905ceebc06 | ||
|
|
87c557941a | ||
|
|
0e1e803486 | ||
|
|
1dc9751a33 | ||
|
|
b81f8f67cc | ||
|
|
f62ffc8d92 | ||
|
|
73ee93deab | ||
|
|
b2fcc0b104 | ||
|
|
8dc601d09a | ||
|
|
85e769a603 | ||
|
|
0363d5f422 | ||
|
|
05b7dac713 | ||
|
|
cfd382e3df | ||
|
|
bbcff40401 | ||
|
|
01001d0980 | ||
|
|
4ac7180cda | ||
|
|
e1683ef09d | ||
|
|
f89fb21502 | ||
|
|
ec147cd1ba | ||
|
|
1f9f14099b | ||
|
|
6cf63113df | ||
|
|
e24a0fa849 | ||
|
|
1c223a2c26 | ||
|
|
a9c90ee6f1 | ||
|
|
dc4a584d5f | ||
|
|
378980de0c | ||
|
|
04c7a52a5a | ||
|
|
52df528994 | ||
|
|
2f00246d6f | ||
|
|
acec77a20c | ||
|
|
59413a8b64 | ||
|
|
cf69cac410 | ||
|
|
b0998fe524 | ||
|
|
8dfab618f7 | ||
|
|
c0c1281415 | ||
|
|
247277e139 | ||
|
|
f3b037c7f4 | ||
|
|
4103e1b0ca | ||
|
|
9d4a13c2c2 | ||
|
|
923f911472 | ||
|
|
272c4918b0 | ||
|
|
ff866cc514 | ||
|
|
e94d731c30 | ||
|
|
0aa0ec2989 | ||
|
|
21b60b007a | ||
|
|
7066e2e06e | ||
|
|
2b76a172e8 | ||
|
|
15495255ef | ||
|
|
3b9b68f4b1 | ||
|
|
7dcd475036 | ||
|
|
fc4877c3bd | ||
|
|
2da679d629 | ||
|
|
9e3f20b050 | ||
|
|
fa816b2e41 | ||
|
|
60d66b728e | ||
|
|
bae3b3505f | ||
|
|
7b1e7ba7c9 | ||
|
|
c0dbea10c5 | ||
|
|
a70ca93102 | ||
|
|
2bda3a26db | ||
|
|
8022502015 | ||
|
|
b07938e351 | ||
|
|
de95df44c1 | ||
|
|
2d7b6a1d5e | ||
|
|
4bbc49fe7e | ||
|
|
db2bf619ce | ||
|
|
6116ed2e21 | ||
|
|
1ba26ee723 | ||
|
|
fcedab0b19 | ||
|
|
72b6ea3803 | ||
|
|
4f25905d81 | ||
|
|
e3eeb3037c | ||
|
|
97e6be3afa | ||
|
|
d67b61d3c6 | ||
|
|
f0d18bb800 | ||
|
|
7f8d925299 | ||
|
|
cdab817141 | ||
|
|
2e76ced5fa | ||
|
|
711fb4838b | ||
|
|
643b30555e | ||
|
|
91671a4f97 | ||
|
|
15be5d2165 | ||
|
|
77994eff09 | ||
|
|
b4038356f0 | ||
|
|
8a87cfeb44 | ||
|
|
3e52eea2da | ||
|
|
acd6784333 | ||
|
|
3e8d609316 | ||
|
|
fcd2443007 | ||
|
|
27a7b04ff3 | ||
|
|
b6cbc6cbc2 | ||
|
|
223a1df85e | ||
|
|
c85bdd5076 | ||
|
|
28825dc7cb | ||
|
|
c930e6163e | ||
|
|
20b8c12b4a | ||
|
|
8d5dd1f1c3 | ||
|
|
c8d67dd07d | ||
|
|
23bd784b07 | ||
|
|
264f48ae11 | ||
|
|
2219f6af13 | ||
|
|
f64b7c1f01 | ||
|
|
868588de10 | ||
|
|
dcb71f4c14 | ||
|
|
968a29ca67 | ||
|
|
fcd6dca852 | ||
|
|
ae673086bc | ||
|
|
e29fd04abd | ||
|
|
be581eb8ae | ||
|
|
9d34b57166 | ||
|
|
4b3abc44fb | ||
|
|
b87c9ea6e2 | ||
|
|
ce1d27f8ca | ||
|
|
cab4e703f5 | ||
|
|
43f8c0ea85 | ||
|
|
8ecefbbdc2 | ||
|
|
6b4448b245 | ||
|
|
e46aaa462a | ||
|
|
98bfa216d6 | ||
|
|
6c3d66108b | ||
|
|
1437667483 | ||
|
|
035d079a8e | ||
|
|
aadfb197b6 | ||
|
|
8aad7c13b0 | ||
|
|
3a02bc8304 | ||
|
|
4aa8bd6a40 | ||
|
|
ce3fc466b2 |
@@ -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.)
|
||||
@@ -3,6 +3,11 @@ name: Documentation
|
||||
# The docs are built on every push so a broken build is caught immediately, but
|
||||
# only published once GitHub Pages has been enabled for the repository. Until the
|
||||
# owner decides to publish, this job builds and stops.
|
||||
#
|
||||
# And on pull requests, where the build is the only check there is: `mkdocs build
|
||||
# --strict` fails on a link to a page that does not exist, and building only after
|
||||
# the merge means finding that out on `main`. Nothing is published from a pull
|
||||
# request — the `deploy` job below runs on a manual dispatch and nothing else.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -10,6 +15,11 @@ on:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -17,6 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Taggen selv, ikke kun den commit den peger på: udgivelsesteksten
|
||||
# nedenfor står i tagbeskeden, og en overfladisk checkout henter
|
||||
# commit'en uden den.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
@@ -60,7 +65,59 @@ jobs:
|
||||
#
|
||||
# Created after the image, so a release never announces a version that
|
||||
# failed to build.
|
||||
# Tagbeskeden ER annonceringen. Den skrives, når der tagges, på produktets
|
||||
# eget sprog og til dem, der bruger det — og indtil nu nåede den aldrig
|
||||
# frem: udgivelsen på GitHub blev fyldt med en autogenereret liste over
|
||||
# commit-overskrifter, som er skrevet til dem, der læser koden.
|
||||
#
|
||||
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
|
||||
# side, der linker til sig selv, hjælper ingen.
|
||||
#
|
||||
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
|
||||
# actions/checkout henter først alle tags korrekt — og overskriver derefter
|
||||
# netop den ene, der udløste kørslen:
|
||||
#
|
||||
# git rev-parse refs/tags/v0.46.0
|
||||
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
|
||||
#
|
||||
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
|
||||
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
|
||||
# commitbeskeden, og det var flettecommittens overskrift, der stod som
|
||||
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
|
||||
#
|
||||
# Derfor hentes tagget som *ref* igen her, før det læses.
|
||||
- id: notes
|
||||
run: |
|
||||
git fetch --force origin \
|
||||
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
|
||||
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
|
||||
| sed '\#^https://github.com/.*/releases/tag/#d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||||
|
||||
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
|
||||
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
|
||||
# frem for at blive opdaget på siden bagefter.
|
||||
case "$body" in
|
||||
"${GITHUB_REF_NAME}"*) ;;
|
||||
*)
|
||||
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
|
||||
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
echo 'body<<VERDANDE_EOF'
|
||||
printf '%s\n' "$body"
|
||||
echo VERDANDE_EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: ${{ steps.notes.outputs.body }}
|
||||
# Stadig sat: den autogenererede liste lægges *under* teksten ovenfor,
|
||||
# så både den, der vil vide hvad der er nyt, og den, der vil se hvilke
|
||||
# ændringer det var, kan læse det samme sted.
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
|
||||
@@ -67,5 +67,9 @@ node_modules.nosync/
|
||||
HANDOFF.md
|
||||
NOTES-PLAN.md
|
||||
|
||||
# Samme grund: instruktionen til flytningen navngiver kassen, runneren, registret
|
||||
# og hvordan man kommer til dem. Arbejdspapir, ikke produktdokumentation.
|
||||
GITEA-FLYTNING.md
|
||||
|
||||
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
# Notes, as verdande builds them
|
||||
|
||||
A description of one working notes system: the data model, the syntax, the search,
|
||||
the rendering contract, the import and export formats, and — more useful than any
|
||||
of it — the decisions that look arbitrary until you know what they cost.
|
||||
|
||||
It is written to be lifted into another program. Nothing here depends on Go,
|
||||
SQLite or Svelte except where it says so; where an answer was forced by the tool,
|
||||
that is called out so you can pick a different one on purpose rather than by
|
||||
accident.
|
||||
|
||||
---
|
||||
|
||||
## Read this part first: what to ask before you build
|
||||
|
||||
**This document is not a drop-in. It describes a notes system that lives inside a
|
||||
task app with projects, roles, a trash and a search box, and about a third of its
|
||||
design is inherited from that.** Lifting it into a program with different
|
||||
surroundings without deciding which parts to keep produces something that works
|
||||
for a week and then fights you.
|
||||
|
||||
**If you are an assistant working from this file: ask these questions and get
|
||||
answers before writing code.** Do not guess a default. Each one changes what you
|
||||
build, not just how you build it.
|
||||
|
||||
### About the surroundings
|
||||
|
||||
1. **Does the host app already have a place to file things** — projects, folders,
|
||||
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
|
||||
filed them under the projects that already existed. If your app has nothing,
|
||||
you need an answer for where a note lives, and "a folder tree" is the expensive
|
||||
one.
|
||||
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
|
||||
through the whole model below. Single-user changes the schema and removes about
|
||||
half the permission surface.
|
||||
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
|
||||
it, deleting is either permanent or you are building a trash.
|
||||
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
|
||||
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
|
||||
ranking section below changes accordingly.
|
||||
|
||||
### About the notes themselves
|
||||
|
||||
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
|
||||
That decision is defended below; it is also the one that most constrains
|
||||
everything else.
|
||||
6. **Is the first line the title, or is there a title field?** Both work. Deriving
|
||||
it means one less thing to keep in step and one less thing a person can leave
|
||||
blank, and it forces the rule "the first line is a heading" on the editor.
|
||||
7. **What links to what?** `#project`, `[[note]]` and task references exist here
|
||||
because the app has projects and tasks. Yours may have none of them, or others.
|
||||
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
|
||||
authorisation path and an XSS surface. Skipping them is a legitimate answer.
|
||||
|
||||
### About scale and language
|
||||
|
||||
9. **How many notes, realistically?** Several choices below flip at around ten
|
||||
thousand: the list payload, the search fallback, rendering the whole list.
|
||||
10. **Which languages?** The folding described under *Search* is Danish. Every
|
||||
language has its own version of that problem and the generic answer (strip
|
||||
diacritics) is wrong for several of them.
|
||||
11. **Where does the text end up besides the screen?** Search indexes, AI
|
||||
features, exports and backups each read it. Plaintext makes all of them cheap
|
||||
and makes a stolen backup expensive — see *What is still open*.
|
||||
|
||||
### About integration, specifically
|
||||
|
||||
12. **Who owns the note's identity — you or the other system?** If notes will sync
|
||||
with something else, ids and timestamps are the whole problem, and the
|
||||
`updated_at` rule below becomes the first thing you have to change.
|
||||
13. **Is this a read of somebody else's notes, or the store of record?** A copy
|
||||
needs conflict rules; a store of record needs export.
|
||||
14. **What happens to a note whose project, task or link target is deleted?**
|
||||
Answered here as "the link goes dead and the note is untouched". If your host
|
||||
app cascades instead, say so before the first delete.
|
||||
|
||||
---
|
||||
|
||||
## The shape of a note
|
||||
|
||||
```
|
||||
id an opaque string, unique
|
||||
project_id nullable — where it is filed, or loose
|
||||
title derived, never set by a caller
|
||||
body Markdown, the whole note
|
||||
created_by nullable — survives the account being deleted
|
||||
pinned favourite; the list puts these first
|
||||
archived_at nullable timestamp — put away, not thrown away
|
||||
deleted_at nullable timestamp — the trash
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
### Markdown in a column
|
||||
|
||||
Not rich text, and not a format of our own.
|
||||
|
||||
Export was a requirement before a line was written, and **if the stored form is
|
||||
the exported form there is no conversion left to lose anything in.** A note stays
|
||||
readable by any editor on the day the program is gone. It is also what makes
|
||||
search and anything an AI does over notes cheap: both read text.
|
||||
|
||||
The cost is real and you should know it before choosing: everything the editor can
|
||||
do has to survive a round trip through Markdown, and anything Markdown cannot say,
|
||||
the editor must not offer. There is no place to hide a feature.
|
||||
|
||||
### The title is the first line
|
||||
|
||||
Always, and re-derived on every save — not "when it is empty".
|
||||
|
||||
Derived once and then left alone, a title goes stale the moment somebody rewrites
|
||||
the opening of a note, and the list ends up calling a note by a name that is
|
||||
nowhere in it. **The column is a cache of the body, not a field beside it**, and no
|
||||
caller can set it, so every caller gets the same answer.
|
||||
|
||||
This forces one editor rule: a new note opens on its title, and the first line is
|
||||
styled as a heading. Otherwise a note whose first line is a sentence is a note
|
||||
named by its first sentence.
|
||||
|
||||
### Archive and trash are two different bits
|
||||
|
||||
The trash says *this was a mistake, and in thirty days it is gone*. The archive
|
||||
says *this is finished, and I do not want to read past it.*
|
||||
|
||||
A twelve-hundred-note import is exactly the case where most notes are worth
|
||||
keeping and almost none are worth looking at. Without an archive, the list is the
|
||||
whole archive forever, and finding this week's note means reading past a decade.
|
||||
|
||||
Both are nullable timestamps rather than booleans: *when was this put away* answers
|
||||
questions a flag cannot, and it costs the same byte.
|
||||
|
||||
---
|
||||
|
||||
## Filing: no hierarchy of its own
|
||||
|
||||
A note belongs to a project, or to nobody.
|
||||
|
||||
The reasoning is worth repeating because it is easy to talk yourself out of:
|
||||
projects already exist, are already shared, already have roles, already have a
|
||||
trash. **A second hierarchy to file things in is one too many**, and a note filed
|
||||
under a project inherits every one of those answers instead of needing its own.
|
||||
|
||||
- A note with a project: everybody who can read the project can read the note; an
|
||||
editor can change it.
|
||||
- A note without one: the author's, and nobody else's.
|
||||
|
||||
Sharing a note *is* filing it in a project. There is no access list of its own —
|
||||
a second way to give somebody access is a second place to get it wrong, and the
|
||||
two would disagree the day one of them changed.
|
||||
|
||||
---
|
||||
|
||||
## Links: three kinds, parsed out of the text
|
||||
|
||||
The body is the truth. Links are an index over it, rebuilt on every save.
|
||||
|
||||
| Syntax | Means | Notes |
|
||||
|---|---|---|
|
||||
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
|
||||
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
|
||||
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
|
||||
|
||||
Patterns as used here:
|
||||
|
||||
```
|
||||
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
|
||||
note \[\[([^\]\n]{1,200})\]\]
|
||||
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
|
||||
```
|
||||
|
||||
Three decisions inside that table:
|
||||
|
||||
- **`#Firma` in a note means the same project as `#Firma` in the task box.** Not a
|
||||
second kind of tag that happens to look alike. That is the whole of "tag a note
|
||||
and see it in the project".
|
||||
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
|
||||
and the set is small; a diff is a second description of the same fact, and it
|
||||
can disagree with the text. That is the one thing this must never do.
|
||||
- **The target is not a foreign key.** A link to something deleted is a fact about
|
||||
the note. It should read as a dead link rather than vanish, and it must not
|
||||
block the delete.
|
||||
|
||||
The index exists so the question can be asked **backwards**: *what has been written
|
||||
about this task?* Answering that by searching every note's prose would be too slow
|
||||
to leave a panel open on.
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
Three layers, in the order they answer.
|
||||
|
||||
### 1. Full-text, ranked
|
||||
|
||||
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
|
||||
|
||||
Ranking is bm25 with **the title weighted ten times the body**. A word in the title
|
||||
is what the note is *about*; the same word once in a long body is a mention.
|
||||
|
||||
This matters more than it sounds. Ordering by "last touched" is invisible with ten
|
||||
notes and useless with twelve hundred imported the same evening: searching "Tesla"
|
||||
answered with a note about nginx first and the note actually called "Tesla Model Y"
|
||||
second — which reads as a search that does not work, because from where the person
|
||||
is sitting it is one.
|
||||
|
||||
### 2. Folding, for the language you are in
|
||||
|
||||
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
|
||||
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
|
||||
typing "gron", which is how people type when the keyboard is not theirs.
|
||||
|
||||
The answer here is a generated column carrying a transliterated copy:
|
||||
|
||||
```
|
||||
ø,Ø → o æ,Æ → ae å,Å → aa
|
||||
```
|
||||
|
||||
indexed alongside the body and **weighted the same as the body** — otherwise a
|
||||
folded match outranks an exact one.
|
||||
|
||||
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
|
||||
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
|
||||
> naive lowercasing entirely. Get the mapping from somebody who types the
|
||||
> language.
|
||||
|
||||
### 3. A word inside a word
|
||||
|
||||
An index is built of tokens, and a token is whatever the tokenizer decided was a
|
||||
word. `gamerule keepInventory true` is three of them, and the middle one is
|
||||
`keepinventory` — so a search for "keep inventory" asks for two words that are
|
||||
nowhere and gets nothing, while the note sits right there.
|
||||
|
||||
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
|
||||
are ANDed, so the half that works is thrown away by the half that cannot.
|
||||
|
||||
**This is not exotic in a notes app.** A decade of notes is full of commands,
|
||||
identifiers and file names, and every one of them is several words the index
|
||||
thinks is one.
|
||||
|
||||
So: the index answers first; when it has nothing, the text is read, and every term
|
||||
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
|
||||
column covers title, body and language folding at once.
|
||||
|
||||
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
|
||||
notes that is a few megabytes and a millisecond or two, and it only runs when the
|
||||
fast path already came back empty — which is exactly the search that was about to
|
||||
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
|
||||
answer then is a trigram index, not a bigger scan.
|
||||
|
||||
It does not rank. There is no match to rank — that is the whole situation.
|
||||
|
||||
---
|
||||
|
||||
## The list, and why the body is empty in it
|
||||
|
||||
A list showing a title and ninety characters of preview must not fetch whole
|
||||
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
|
||||
see.
|
||||
|
||||
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
|
||||
|
||||
A field called `body` holding a *piece* of the body is a trap. The editor opens
|
||||
it, the autosave pause writes it back, and the note is now cut down to its own
|
||||
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
|
||||
its own field and leave `body` empty; the client sees the empty body and fetches
|
||||
the whole note.
|
||||
|
||||
That costs one more separation, and it is not optional:
|
||||
|
||||
> **Which row is selected and which note is loaded are two different things.**
|
||||
|
||||
The editor loads its text when the note's id changes. Hand it the excerpt first
|
||||
and the whole note second and the id is the same both times — so the second one
|
||||
never renders, and somebody sits looking at a note that had a title a moment ago.
|
||||
|
||||
Highlight the row immediately from the list; give the editor the note only when it
|
||||
is whole.
|
||||
|
||||
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
|
||||
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
|
||||
virtual list, no scroll maths, and no list that can fall out of step with itself.
|
||||
Try that before building windowing.
|
||||
|
||||
---
|
||||
|
||||
## The editor contract
|
||||
|
||||
Rich text on top, Markdown underneath, and a source view that shows the file.
|
||||
|
||||
The supported subset — deliberately small, because everything in it has to survive
|
||||
the round trip:
|
||||
|
||||
| Block | Markdown |
|
||||
|---|---|
|
||||
| Title | `# ` |
|
||||
| Heading | `## ` |
|
||||
| Subheading | `### ` |
|
||||
| Quote | `> ` |
|
||||
| Code block | ```` ``` ```` with an optional language |
|
||||
| Bullet and numbered lists | `- `, `1. `, nested |
|
||||
|
||||
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
|
||||
and images as ``.
|
||||
|
||||
Five rules that were each learned the hard way:
|
||||
|
||||
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
|
||||
lines into a paragraph. Here it must not: the person typed a line and expects
|
||||
to get it back, and an editor that silently reflows what somebody wrote is an
|
||||
editor they stop trusting.
|
||||
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
|
||||
the caret back at the start, which mid-sentence is unusable.
|
||||
3. **One definition of what a code block contains.** A copy button inside the
|
||||
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
|
||||
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
|
||||
the code and the next save put it on disk. Have one function that says what a
|
||||
block holds, used by the highlighter and the Markdown serialiser both.
|
||||
4. **A note that cannot be rendered must not take the pane with it.** One note
|
||||
arriving in an unexpected shape threw inside the editor's reactive effect, and
|
||||
from then on *no* note would open — the previous one just stayed on screen. Put
|
||||
a guard around the render, fall back to showing the raw text, and let the rest
|
||||
of the pane keep working.
|
||||
5. **Escaping between tags and escaping inside an attribute are different jobs.**
|
||||
An escaper written for text between two tags handles `&`, `<`, `>` — and not
|
||||
quotes, because between tags they are harmless. Inside an attribute they close
|
||||
it. `` is the whole exploit.
|
||||
|
||||
---
|
||||
|
||||
## Attachments
|
||||
|
||||
Images pasted or dragged into a note, stored content-addressed by hash, referenced
|
||||
from the body as ordinary Markdown images.
|
||||
|
||||
The security shape, which is the part worth copying:
|
||||
|
||||
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
|
||||
are the one place a user supplies content another user opens. An uploaded SVG or
|
||||
HTML file rendered inline runs its own script on your origin with the session
|
||||
cookie attached.
|
||||
- **One exception, as a named allowlist:** a short list of raster image types
|
||||
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
|
||||
A note carries its pictures in the text, and served as octet-stream every one of
|
||||
them is a broken image.
|
||||
- **The list is named one type at a time, never matched on an `image/` prefix.**
|
||||
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
|
||||
script.
|
||||
- **The stored path is checked against the files directory again at read time.** A
|
||||
traversal that somehow got into the database must not become one that reads
|
||||
`/etc/passwd`.
|
||||
- **Reaching an attachment follows the note, which follows its project.** Same
|
||||
chain as everything else. Do not let a client hand you the parent id and take it
|
||||
on trust.
|
||||
|
||||
---
|
||||
|
||||
## Import and export
|
||||
|
||||
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
|
||||
front matter block.
|
||||
|
||||
```markdown
|
||||
---
|
||||
created: 2019-04-02T10:15:00Z
|
||||
modified: 2024-11-30T21:04:00Z
|
||||
---
|
||||
|
||||
# Møde med Anders
|
||||
|
||||
Han vil gerne have kaffe hver uge.
|
||||
```
|
||||
|
||||
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
|
||||
all accepted on the way in. Obsidian and Bear write this block; so should you.
|
||||
- Images travel in the zip beside the notes, and the links in the text are
|
||||
relative — **a note and its pictures must end up in the same archive**, which
|
||||
matters the moment an export is split into parts.
|
||||
- File names collide: two notes called "Møde" are two notes, and a zip with one
|
||||
entry twice is a zip that loses one.
|
||||
|
||||
Limits that a real import taught, all of them cheap to add and expensive to omit:
|
||||
|
||||
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
|
||||
64 MB of zip expanded to 66 GB is a normal shape for an attack.
|
||||
- A per-file limit as well as a whole-archive budget: one enormous photograph and
|
||||
ten thousand small ones are different attacks with the same ending.
|
||||
- The entry name is never used as a path. Store by hash; the name is only a key
|
||||
for rewriting links.
|
||||
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
|
||||
dropped silently by a filter looking for `..` — one out of 1141, found only by
|
||||
counting at both ends.
|
||||
|
||||
### Timestamps on the way in
|
||||
|
||||
An importer needs to write `created_at` and `updated_at` from the file, and the
|
||||
normal save path must refuse to let it.
|
||||
|
||||
`updated_at` means *when did this program last write this note*. Let any caller set
|
||||
it and the column means whatever the last writer felt like. Give the importer its
|
||||
own narrow entry point instead, and say in the comment why it exists.
|
||||
|
||||
Without those dates, a move of twelve hundred notes is a pile that was all written
|
||||
the same evening — and the order they were written in is half of what an archive
|
||||
is.
|
||||
|
||||
---
|
||||
|
||||
## What is still open here
|
||||
|
||||
Written down because a specification that only lists what works is a specification
|
||||
that lies by omission.
|
||||
|
||||
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
|
||||
they are in every backup anybody can download. Encrypting them is bigger than it
|
||||
sounds: the body is in the full-text index and the folded column is generated
|
||||
from it, so both are a second plaintext copy in the same database. Encrypt and
|
||||
both have to go, and search has to be rewritten to decrypt in application code —
|
||||
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
|
||||
point the answer is a keyed index (an HMAC per word).
|
||||
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
|
||||
- **There is no sync.** Export and the API are the way out.
|
||||
|
||||
---
|
||||
|
||||
## A short list of the traps, for the person in a hurry
|
||||
|
||||
1. A `body` field holding an excerpt will be saved over the real one.
|
||||
2. Selected row and loaded note are two different pieces of state.
|
||||
3. The title must be re-derived on every save or it goes stale.
|
||||
4. Diacritic stripping is not folding, and folding is language-specific.
|
||||
5. A tokenizer cannot see a word inside a word; decide what you do about it.
|
||||
6. An attachment served inline is a script on your origin unless you allowlist by
|
||||
exact type and send `nosniff`.
|
||||
7. Escaping for text and escaping for attributes are different functions.
|
||||
8. One render failure must not be allowed to freeze the whole pane.
|
||||
9. An unpacked-size limit is not optional on an archive import.
|
||||
10. Let the importer set timestamps; let nobody else.
|
||||
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
|
||||
integrations that a hosted product will not build for you: a real CalDAV server, a
|
||||
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data, and with the integrations that a
|
||||
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
|
||||
and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -36,17 +37,32 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||
views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project, by
|
||||
handing it to one person — including somebody who has no account yet, who gets an
|
||||
invitation that becomes the share when they sign up — or by writing `@Sofie` in
|
||||
the text, which shares it with her and tells her. `#project` and
|
||||
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
everybody arrives through an invite link, either to a project or — from
|
||||
**Settings → Brugere** — to the instance itself. Nobody's password is ever chosen
|
||||
by somebody else.
|
||||
everybody arrives through an invite link — to a project, to a single note, or
|
||||
from **Settings → Brugere** to the instance itself. Nobody's password is ever
|
||||
chosen by somebody else.
|
||||
- **Live sync.** A change made by somebody else appears without a refresh.
|
||||
- **Notifications that are worth reading.** A bell for what other people did to what
|
||||
you share — folded, so one person typing for five minutes is one line — pushed to
|
||||
your devices and counted on the app's icon in the Dock.
|
||||
- **AI that suggests and never writes.** Optional, off by default, your own key and
|
||||
your own model if you want. It tidies the inbox, pulls the tasks out of a meeting
|
||||
note, writes a morning plan, turns *"SV: SV: Vedr. levering uge 12"* into the
|
||||
action the mail asks for, and answers questions about your own notes with links to
|
||||
what the answer rests on. Every one of them arrives as a line you accept, edit or
|
||||
refuse — a model that edits your tasks directly has to be right every time to be
|
||||
worth having.
|
||||
- **Anything can put a task in the inbox.** A mail to your own forwarding address,
|
||||
or a POST of one line of text from a shortcut on your phone, a script or any
|
||||
service that can call a URL.
|
||||
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
|
||||
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
|
||||
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
|
||||
@@ -102,17 +118,23 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| CalDAV server (two-way VTODO) | Done, tested |
|
||||
| Mail-to-task | Done, tested |
|
||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
|
||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||
| Notes: archive, grouped list, images, source view | Done, tested |
|
||||
| Update notice for administrators | Done, tested |
|
||||
| Personal API tokens | Done, tested |
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
|
||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
||||
API calls are implemented against their specifications and have only ever been
|
||||
exercised there, not against live services. Everything else has an interface: opening a task shows
|
||||
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
not against live services. Gmail is no longer in that list — it runs in production,
|
||||
including the flagging and de-duplication that took a day to get right. Calendar
|
||||
subscriptions over ICS and webcal are proven; the Google half of the calendar is
|
||||
not. Everything else has an interface: opening a task shows
|
||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||
templates.
|
||||
|
||||
@@ -10,13 +10,28 @@ reply within a week.
|
||||
|
||||
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
|
||||
be raised later without locking anyone out.
|
||||
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
|
||||
stored only as a SHA-256 hash. A database dump contains nothing that can be
|
||||
presented.
|
||||
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
|
||||
database dump contains nothing that can be presented.
|
||||
- **The calendar feed address and the mail-to-task address are not hashed.** They
|
||||
are looked up by their exact value, and they are shown in the interface more
|
||||
than once, which a hash cannot do. Anybody holding a backup file therefore holds
|
||||
those two URLs: the feed reads that person's tasks without a login, and the mail
|
||||
address files tasks into their inbox. Rotating either from **Settings →
|
||||
Integrations** invalidates the old one. This is written down rather than fixed
|
||||
because fixing it means the address can only ever be shown once, which is a
|
||||
decision about how the feature works and not only about how it is stored.
|
||||
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
|
||||
AES-GCM under a key kept outside the database — see `internal/secret`. That is
|
||||
what makes a stray copy of the database less than a stray copy of everything:
|
||||
the backup does not carry the key.
|
||||
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
|
||||
- **Uploads** are stored content-addressed by hash and always served as
|
||||
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
|
||||
on this origin.
|
||||
- **Uploads** are stored content-addressed by hash and served as
|
||||
`application/octet-stream` with `nosniff` — with one exception, added so that a
|
||||
note can show the pictures in it: a short allowlist of raster image types is
|
||||
served inline as its own type. The list is named one format at a time rather
|
||||
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
|
||||
through, and an SVG is a document that can run script. `nosniff` stays, so the
|
||||
browser must read the file as that image or not at all.
|
||||
- **The filter language** compiles to parameterised SQL. No typed text is ever
|
||||
concatenated into a statement.
|
||||
- **Permission checks** are route middleware, and anything you may not see answers
|
||||
@@ -24,8 +39,28 @@ reply within a week.
|
||||
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
||||
page, rather than allowing `unsafe-inline`.
|
||||
|
||||
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
|
||||
only the body: a note's title *is* its first line, so a readable title beside a
|
||||
sealed body would leave the beginning of every note in the clear.
|
||||
|
||||
The two plaintext copies went with it. `notes_fts` indexed the body and the
|
||||
generated `fold` column held a transliterated copy of it — sealing the body while
|
||||
leaving those would have been theatre, since a stolen file could be read straight
|
||||
out of the index. Search opens the text in Go instead, which costs 33–43 ms
|
||||
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
|
||||
- **Task titles, descriptions and comments are still in the clear**, and so is
|
||||
`note_links` — ids derived from a note's body, so a stolen file still shows which
|
||||
projects and tasks a note points at, though not what it says.
|
||||
|
||||
## What it does not do
|
||||
|
||||
verdande has no rate limiting beyond the login and password-reset endpoints, and
|
||||
no audit log of reads. It is built to be run behind something — a Cloudflare
|
||||
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
|
||||
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
|
||||
password, the second factor, the installation beacon, and inbound mail. **Nothing
|
||||
else is limited** — an authenticated caller can ask as often as they like, and a
|
||||
signed-in account that wanted to be a nuisance can be one.
|
||||
|
||||
There is no audit log of reads. Who *changed* something is recorded per project;
|
||||
who looked at it is not.
|
||||
|
||||
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
|
||||
VPN — by somebody who knows what they exposed.
|
||||
|
||||
@@ -64,6 +64,32 @@ func run() error {
|
||||
}
|
||||
db.UseSecrets(box)
|
||||
|
||||
// Noter, der stadig ligger åbne, lukkes nu.
|
||||
//
|
||||
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
|
||||
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
|
||||
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
|
||||
// så den koster et gennemløb af id'er hver anden gang.
|
||||
//
|
||||
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
|
||||
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
|
||||
// er værre end en, der siger fra.
|
||||
if sealed, err := db.SealNotes(context.Background()); err != nil {
|
||||
return fmt.Errorf("seal notes: %w", err)
|
||||
} else if sealed > 0 {
|
||||
log.Info("notes sealed", "count", sealed)
|
||||
}
|
||||
|
||||
// Names created before the app capitalised them on the way in — "andreas"
|
||||
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
||||
// caught up. A failure here does not stop the start: a lower-case first name is
|
||||
// a blemish, not a broken instance.
|
||||
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
||||
log.Error("normalize user names", "err", err)
|
||||
} else if fixed > 0 {
|
||||
log.Info("user names normalized", "count", fixed)
|
||||
}
|
||||
|
||||
httpapi.Version = version
|
||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||
|
||||
@@ -78,6 +104,8 @@ func run() error {
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.DailyPlans = api.DailyPlans
|
||||
runner.NotifyOfUpdates = api.NotifyOfUpdates
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -43,23 +43,92 @@ The key is stored on your account and is **never sent back** to the settings pag
|
||||
a page that repopulates a password field is one that will eventually leak it into a
|
||||
screenshot.
|
||||
|
||||
## The shape of all of it
|
||||
|
||||
**The model proposes; you decide.** Nothing is written until you say yes, and the
|
||||
proposal is a line in the same syntax you type yourself — `Ring til Anders i morgen
|
||||
p1 #Firma` — sitting in a field you can edit before you accept it.
|
||||
|
||||
That is not caution for its own sake. A model that edits your tasks directly has to
|
||||
be right *every* time to be worth having. One that suggests only has to be right
|
||||
often enough to save you some typing, and when it is wrong you have already seen it.
|
||||
|
||||
It is also why the suggestion is a line rather than a set of fields: there is one
|
||||
reading of "i morgen" in this program, and a suggestion goes through the same
|
||||
[quick add](quick-add.md) parser as anything you type — including after you have
|
||||
edited it by hand.
|
||||
|
||||
The model is given the names of the projects that exist and may name no others. A
|
||||
`#Projekt` it invented would create one by accident the first time you accepted it,
|
||||
and a guess must not be able to move a task somewhere you will not look for it.
|
||||
|
||||
## What it can do
|
||||
|
||||
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
|
||||
seven concrete actions and creates them as sub-tasks. Written in your language.
|
||||
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
|
||||
line it suggests how you would have written it if you had finished it — project,
|
||||
date, priority — with one sentence on what it read out of the task. Accept, edit, or
|
||||
skip, one line at a time.
|
||||
|
||||
**Find the tasks in a note.** Open a note → *Find opgaver i noten*. Only what
|
||||
somebody committed to or was asked to do; a heading is not a task and a topic that
|
||||
was discussed is not a task. Each suggestion quotes the words it came from, so a
|
||||
wrong one can be seen through rather than merely being wrong. The note itself is
|
||||
never edited — the task carries `[[the note's title]]` in its description instead.
|
||||
|
||||
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
|
||||
default; pick the hour yourself. One notification about what is due today and what
|
||||
has been missed, and what to start with — and the same text as a card at the top of
|
||||
**I dag**, where you are looking anyway when you start the day. The notification is
|
||||
the reminder; the card is where it stays.
|
||||
|
||||
The card shows the plan *as it was sent*, with the time it was made, rather than a
|
||||
new one each time you look: a plan that says something different when you look at it
|
||||
than it said when it arrived is worse than either. **Forny planen** makes a new one
|
||||
when the day has moved, and it does not send a notification about something you are
|
||||
already looking at. With the morning notification off, the card offers to make one
|
||||
on the spot.
|
||||
|
||||
The counts are done in the database and handed to the model as facts it may not
|
||||
recompute — a plan that says four tasks where there are three is worse than no plan.
|
||||
With no model configured the counts stand on their own, which is what you needed to
|
||||
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
|
||||
|
||||
**Mail that becomes a task you can act on.** Where a
|
||||
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
|
||||
12"*, the model writes the action the mail asks for — and leaves the mail alone if
|
||||
it asks for nothing. The subject stays in the description, so a mail it misread can
|
||||
still be read. If the model is unavailable the task is named sender and subject as
|
||||
before: a model that is down must not be able to stop the post.
|
||||
|
||||
**Ask your own notes and tasks.** ⌘K, type the question, ⌘⏎. *"Hvad lovede jeg
|
||||
Anders i august?"* — search finds what contains the words, this answers the
|
||||
question, and the answer links to the notes and tasks it rests on. If nothing
|
||||
matches, it says so; a guess would be useless here, because you are asking precisely
|
||||
because you cannot remember.
|
||||
|
||||
**Split a task into sub-tasks.** On any task: *Split with AI*. Two to seven concrete
|
||||
actions, created as sub-tasks.
|
||||
|
||||
**A weekly summary.** A short note over what is outstanding: what looks urgent and
|
||||
what appears to be slipping. At most six lines — it is a prompt to think, not a
|
||||
report.
|
||||
what appears to be slipping. At most six lines — a prompt to think, not a report.
|
||||
|
||||
## What is sent
|
||||
|
||||
Only what the feature needs: for a split, the task's title and notes; for a
|
||||
summary, the titles, dates and priorities of your open tasks. Never comments,
|
||||
attachments, or anything from a project the feature was not invoked on.
|
||||
Only what the feature needs, and only when you invoke it:
|
||||
|
||||
Nothing is sent to anybody until you configure a provider. With a local model,
|
||||
nothing leaves your machine at all.
|
||||
| Feature | What leaves the instance |
|
||||
| --- | --- |
|
||||
| Tidy the inbox | The titles of the loose tasks in your inbox, the first words of their descriptions, and your project names |
|
||||
| Tasks in a note | That one note's text, and your project names |
|
||||
| Today's plan | The titles and dates of what is due or overdue today |
|
||||
| Mail to task | Sender, subject and the snippet of the mails in that sweep |
|
||||
| Ask | Your question, and the notes and tasks the search matched |
|
||||
| Split a task | That task's title and notes |
|
||||
| Weekly summary | The titles, dates and priorities of your open tasks |
|
||||
|
||||
Never comments, never attachments, never a project the feature was not invoked on,
|
||||
and never anything belonging to somebody else. Nothing is sent to anybody until you
|
||||
configure a provider, and with a local model nothing leaves your machine at all.
|
||||
|
||||
## Turning it off
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ what a person reads is decided by the client, which knows what language they are
|
||||
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
-H "Authorization: Bearer vrd_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
|
||||
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
|
||||
```
|
||||
|
||||
### Projects and sections
|
||||
@@ -96,9 +96,10 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
|
||||
| `GET` `POST` | `/projects/{id}/sections` | |
|
||||
| `PATCH` `DELETE` | `/sections/{id}` | |
|
||||
| `GET` | `/projects/{id}/members` | |
|
||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
|
||||
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
|
||||
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
|
||||
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
|
||||
| `GET` | `/projects/{id}/activity` | |
|
||||
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||
@@ -108,7 +109,7 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| Method | Path | |
|
||||
|---|---|---|
|
||||
| `GET` | `/today` | Overdue and due today, in your timezone. |
|
||||
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
|
||||
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. Up to 31; over that is capped. |
|
||||
| `GET` | `/search?q=` | Across everything you can see. |
|
||||
| `GET` `POST` | `/filters` | |
|
||||
| `GET` | `/filters/{id}/tasks` | Run one. |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# The install count
|
||||
|
||||
verdande reports that it exists. Two values, once a day:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance_id": "01a0806d-3601-7000-ad76-057f1f684881",
|
||||
"version": "v0.44.1"
|
||||
}
|
||||
```
|
||||
|
||||
That is the whole message. No IP is stored by the collector, no hostname, no
|
||||
domain, no account, no counts of anything you have made, nothing about what you
|
||||
use it for. The payload is small enough to be printed in full on the settings page,
|
||||
and it is printed there — a promise about telemetry is worth exactly as much as
|
||||
your ability to check it.
|
||||
|
||||
## On by default, off in one click
|
||||
|
||||
The alternative that was considered first was compulsory, and the argument against
|
||||
it is practical rather than principled: it cannot be enforced. The domain can be
|
||||
blocked, the binary can be patched, and a fork removes it in its first commit. So
|
||||
mandatory buys nothing over on-by-default while costing the trust the rest of this
|
||||
program works for.
|
||||
|
||||
Which is why the first administrator to sign in is **told, unprompted**, with the
|
||||
real id, the real version and the real address in front of them, and two buttons.
|
||||
On by default is only defensible if it is said out loud without being asked; a
|
||||
settings page where it is stated fully is not the same thing, because that page is
|
||||
opened by people who already suspect there is something to read.
|
||||
|
||||
**Settings → Data** afterwards: one switch. An explicit "off" survives upgrades.
|
||||
|
||||
!!! note "This disagrees with the update check, on purpose"
|
||||
|
||||
`VERDANDE_UPDATE_CHECK` is off unless you ask for it, and its reasoning is that
|
||||
a self-hosted app reaching out unbidden has broken the deal its operator made by
|
||||
self-hosting. That is the same situation as this one, and the two defaults do
|
||||
not agree. The case for treating them differently: the update check reveals an
|
||||
instance to a third party (GitHub) and returns something you can act on, while
|
||||
this returns nothing and goes to the project. Whether that is a distinction or
|
||||
an excuse is a judgement call, and it was made deliberately.
|
||||
|
||||
## What the collector cannot see, and what it can
|
||||
|
||||
A request has a source address whether or not anybody writes it down. The collector
|
||||
stores none and logs none, and the code is arranged so the address never reaches a
|
||||
function that could — the table has nowhere to put one. But the request passes
|
||||
through a reverse proxy and a tunnel on the way, and those keep their own logs.
|
||||
"We send no IP" would be a half-truth; what is true is that nothing here records
|
||||
one.
|
||||
|
||||
The ping is not authenticated either, so anybody can post a made-up id and inflate
|
||||
the count. That is an accepted trade: a token would have to ship inside every
|
||||
install anyway.
|
||||
|
||||
## Running your own count
|
||||
|
||||
Somebody running a fleet points **Settings → Data → Modtagerens adresse** at their
|
||||
own instance, and ticks **Denne instans er tælleren** there. Until that is ticked,
|
||||
`POST /api/v1/beacon` answers **404** rather than accepting quietly — an instance
|
||||
nobody made a collector must not become a bucket anyone can post into.
|
||||
|
||||
The collector's page then shows totals, active in 7 and 30 days, and a breakdown by
|
||||
version. Publishing that number at `/api/v1/beacon/count` is a second, separate
|
||||
opt-in, and it has a floor under it (25 by default): a number that *falls* cannot be
|
||||
published as a fact — a bad release, a DNS change that quietly stops the pings, or
|
||||
lost rows all look like "fewer people use this now" — and a small number mostly says
|
||||
how little it takes to move it.
|
||||
|
||||
## When it cannot reach
|
||||
|
||||
The reason is written down and shown on the settings page. A beacon that cannot
|
||||
reach its collector otherwise fails in complete silence: it tries again tomorrow,
|
||||
the page says "last sent" with a date that quietly gets older, and nobody notices.
|
||||
@@ -1,9 +1,10 @@
|
||||
# Calendar and CalDAV
|
||||
|
||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
||||
verdande, and none of the three is the same thing.
|
||||
Two ways to get your tasks into a calendar. Getting somebody else's calendar
|
||||
*into* verdande is the other direction and has its own page — see
|
||||
[Calendar](calendar.md).
|
||||
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [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 |
|
||||
| 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
|
||||
|
||||
The two sections above send your tasks *out*. This is the other direction: your
|
||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
||||
already have in it".
|
||||
Moved. Connecting a Google account, subscribing to an address, and what the grid
|
||||
does with either now live on their own page — see [Calendar](calendar.md).
|
||||
|
||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
||||
own colour for each one, beside your own tasks with due dates.
|
||||
|
||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
||||
|
||||
!!! warning "Read only"
|
||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
||||
can be changed. Your own tasks in the same grid still drag to another day as
|
||||
they always have.
|
||||
|
||||
Writing would need the `calendar.events` scope and an answer to what should
|
||||
happen when both sides moved the same meeting — which is a synchronisation
|
||||
model rather than a scope, and it is deliberately not built yet.
|
||||
|
||||
!!! note "The Internal client applies here too"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace, calendars included. A personal
|
||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
||||
this feature.
|
||||
|
||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
||||
says so rather than showing an empty month.
|
||||
This page is the other direction: verdande's own tasks, read by something else.
|
||||
|
||||
## 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_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
verdande keys its login rate limit and its audit log on the caller's address, so
|
||||
that address has to be one the caller cannot forge. A forwarded-for header is
|
||||
believed only when the machine that connected is a trusted proxy; a connection
|
||||
straight off the internet is keyed on the address it really came from.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
|
||||
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
|
||||
|
||||
!!! warning "A proxy that does not overwrite the header"
|
||||
The default is safe when your proxy replaces a client-supplied
|
||||
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
|
||||
do. If yours passes the client's own header through, narrow
|
||||
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
|
||||
choose the address they are rate-limited and logged as.
|
||||
|
||||
## Sessions and links
|
||||
|
||||
| Variable | Default | What it does |
|
||||
@@ -55,7 +74,7 @@ Then enable the APIs and scopes for whichever you want:
|
||||
|
||||
| For | Enable | Scope |
|
||||
|---|---|---|
|
||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
||||
| Mailboxes | Gmail API | `gmail.modify` |
|
||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||
|
||||
| Variable | What it does |
|
||||
@@ -108,11 +127,17 @@ next time each value is written, so turning this on costs nobody a reconnection.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists, and tell the administrators when one does. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
|
||||
With the check on, administrators are told **once per version** — in the bell, in
|
||||
push and on the app's badge — rather than having to open the settings page and
|
||||
look. The notification leads to the page where the restart button is. With the
|
||||
check off nothing is fetched and nothing is said; see
|
||||
[Notifications](notifications.md).
|
||||
|
||||
All three together let **Settings → Notifications** restart this instance,
|
||||
which is also how it picks up a new version: a container cannot replace its own
|
||||
image, so the panel recreates it and pulls `:latest` on the way. The request asks
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Saved filters
|
||||
|
||||
A filter is a small expression describing a slice of your tasks. The grammar is
|
||||
Todoist's, so if you are coming from there you already know it.
|
||||
A filter is a small expression describing a slice of your tasks. The grammar will
|
||||
feel familiar if you have used query-based filters in other task apps.
|
||||
|
||||
```
|
||||
today & p1
|
||||
|
||||
@@ -11,28 +11,64 @@ of. Which is what a to-do list is.
|
||||
|
||||
## What it is for
|
||||
|
||||
Todoist Pro, on your own hardware, with your own data. And with the integrations a
|
||||
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||
lists — on your own hardware, with your own data. And with the integrations a
|
||||
hosted product will not build for you: a real CalDAV server, an address you can
|
||||
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
|
||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
|
||||
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
|
||||
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
|
||||
line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
to move it.
|
||||
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
|
||||
settings themselves, by what they *do* rather than only by what they are called,
|
||||
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
|
||||
settings pages carry the same field at the top of their own list.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||
single note with a person — by name, by email address, or by writing `@Sofie` in
|
||||
the text — or file it in a project; `#project` and `[[another note]]` are links,
|
||||
and a task shows what has been written about it.
|
||||
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
||||
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
||||
greyed, at the foot of the list until a moment you pick — without touching when
|
||||
it is due.
|
||||
- **Drag where it means something** — reorder a list or a board, file a project
|
||||
under a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||
joins through an invite link.
|
||||
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
|
||||
account here you pick by name — the accounts on an instance are a closed,
|
||||
invited set, so they are an address book rather than a directory to protect.
|
||||
Anyone outside your instance joins through an invite link, and the invitation
|
||||
stays visible until they arrive, so it does not get sent twice.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **Repeating tasks** as RRULE, reminders, comments and attachments.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
- **[Told when it matters](notifications.md).** A bell for what other people did to
|
||||
what you share, pushed to your devices and counted on the app's icon in the Dock.
|
||||
- **[AI that suggests and never writes](ai.md).** Optional, your own key. It tidies
|
||||
the inbox, pulls the tasks out of a meeting note, writes a morning plan, turns
|
||||
*"SV: SV: Vedr. levering uge 12"* into what the mail actually asks for, and
|
||||
answers questions about your own notes with links to what the answer rests on —
|
||||
every one of them as a line you accept, edit or refuse.
|
||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||
out as Web Push to an iPhone or a Mac.
|
||||
- **[Anything can put a task in your inbox](mail.md)** — a mail to your own
|
||||
address, or a POST from a shortcut on your phone, a script, or any service that
|
||||
can call a URL.
|
||||
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||
of only filling up.
|
||||
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
|
||||
rather than private formats.
|
||||
- **Six themes**, three dark and three light — including *Charcoal*, a warm
|
||||
near-black after Apple Notes — and a size and typeface chosen apart from them.
|
||||
- **Holds through a short network drop.** A save rides out a few seconds of no
|
||||
signal and goes through when it comes back; a reload opens from what it last saw
|
||||
rather than the browser's offline page. It installs as a PWA.
|
||||
|
||||
## What it looks like
|
||||
|
||||
@@ -48,7 +84,19 @@ columns.*
|
||||
|
||||

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

|
||||
|
||||
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
|
||||
amber where the accent green usually is.*
|
||||
|
||||

|
||||
|
||||
*Six themes — three dark, three light — chosen on the settings page, with the
|
||||
typeface and how tightly it sits set apart from them.*
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Opening links
|
||||
|
||||
A link in a task or a note opens in whichever browser your system opens links in.
|
||||
**Settings → Links** can try to change that — and the honest description of what it
|
||||
does is *try*.
|
||||
|
||||
## What a web page cannot do
|
||||
|
||||
Two things, and they set the limits of this feature:
|
||||
|
||||
- **It cannot see which browsers are installed.** The list is deliberately closed:
|
||||
it would be a fingerprint, distinctive enough to follow somebody from site to
|
||||
site.
|
||||
- **It cannot ask the system to use a particular one.** A link goes to the default
|
||||
browser, and there is no API that says otherwise.
|
||||
|
||||
The only handle that exists is the URL schemes browsers register for themselves —
|
||||
`firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…` — and there is no
|
||||
way to ask whether one exists. You can only try it.
|
||||
|
||||
## So the setting is an attempt
|
||||
|
||||
Pick Safari, Chrome, Firefox or Edge, or write your own scheme. When you click a
|
||||
link, that scheme is tried first; if nothing has happened within about half a
|
||||
second, the link opens the ordinary way.
|
||||
|
||||
The fallback is the whole reason this is safe to turn on. Without it, a setting
|
||||
that works on your phone would leave links doing nothing on your desktop.
|
||||
|
||||
In practice: **it works on a phone**, where browsers register these schemes, and
|
||||
**rarely on a Mac or a PC**, where Chrome and Firefox do not. Edge on Windows is
|
||||
the usual exception. There is a **Prøv det** button for exactly this reason — you
|
||||
cannot ask whether a scheme exists, so it is better to find out on example.dk than
|
||||
on a link you meant to follow.
|
||||
|
||||
## Writing your own
|
||||
|
||||
Three placeholders, because browsers disagree about the shape they want the address
|
||||
in:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `{url}` | The address as it is — `x-safari-https://example.dk` |
|
||||
| `{encoded}` | The address as a parameter value — `firefox://open-url?url=https%3A%2F%2F…` |
|
||||
| `{stripped}` | The address without the `https://` — `googlechrome://example.dk` |
|
||||
|
||||
## It belongs to the machine, not the account
|
||||
|
||||
The choice is kept in the browser you are sitting at, not on your account — the
|
||||
same rule the sidebar's width follows. `firefox://` works on the phone and does
|
||||
nothing on the Mac, so a choice that followed the account would be wrong in one of
|
||||
the two places every time.
|
||||
@@ -67,3 +67,36 @@ immediately.
|
||||
|
||||
!!! tip "Keep the address to yourself"
|
||||
Treat it like a secret. If you publish it somewhere, rotate it.
|
||||
|
||||
## Pushing from another program
|
||||
|
||||
The address above needs a mail server. When what you want is a shortcut on your
|
||||
phone, a script, or any service that can call a URL, there is a second way in:
|
||||
**Settings → Integrationer → Skub fra andre programmer** gives you an address to
|
||||
POST a line of text to.
|
||||
|
||||
```bash
|
||||
curl -X POST https://todo.example.dk/inbound/hook/<token> \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Ring til Anders i morgen p1 #Firma"
|
||||
```
|
||||
|
||||
The body is read three ways, because those are the three you meet: plain text from
|
||||
a shortcut that just sends what was selected, JSON with a `text` field from a
|
||||
service, or a form field from a webhook form. The first line becomes the task and
|
||||
the rest sits underneath it, and the line goes through the [quick add](quick-add.md)
|
||||
parser — so "i morgen", "p1" and "#Firma" mean here what they mean in the box at the
|
||||
top of the app.
|
||||
|
||||
**The address is a key.** It has its own token rather than sharing the mail one:
|
||||
the two are the same kind of secret but they leak separately, and a URL that has
|
||||
been sitting in a shortcut on a lost phone has to be replaceable without changing
|
||||
the address other people have in their address books. A token here can do one
|
||||
thing — put a task in your inbox — which is why it is not an
|
||||
[API token](api.md), which can do everything. Change it under the same setting; the
|
||||
old one stops working at once.
|
||||
|
||||
**POST only.** A GET would be easier to call, and that is exactly the problem: a
|
||||
browser prefetch, a link check in a chat, and a revisited history entry would all
|
||||
create tasks nobody asked for.
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ Both endpoints run the same server and reach the same things.
|
||||
| `complete_task` | Tick one off. |
|
||||
| `add_comment` | Comment on a task. |
|
||||
|
||||
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
|
||||
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
|
||||
labels and recurrence in either language.
|
||||
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
|
||||
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
|
||||
projects, labels and recurrence in either language.
|
||||
|
||||
## What it can reach
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
|
||||
**Click one to see it whole.** In the note it is scaled down to the width of the
|
||||
column — a photo off a phone is four thousand pixels across — so what you are
|
||||
looking at is a thumbnail that does not look like one. The full-size view has a
|
||||
button that copies the picture to the clipboard, and a link that opens it in its
|
||||
own tab. Escape closes it and puts the cursor back where it was.
|
||||
|
||||
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
|
||||
little else, and a photo off a phone is a JPEG — without the conversion the copy
|
||||
either fails or succeeds with something nothing will paste.
|
||||
|
||||
### 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.
|
||||
|
||||
### Sharing one note with one person
|
||||
|
||||
Filing a note in a project 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*. **Del med personer**
|
||||
at the foot of the note does the second: this note, this person, as reader or
|
||||
editor. Only the note's owner can share it.
|
||||
|
||||
Type an **email address** rather than picking a name, and one of two things
|
||||
happens. An address that already has an account is shared with straight away. One
|
||||
that does not gets an invitation — a link that creates the account and the share
|
||||
together, so the note is theirs the moment they sign up. Invitations wait in the
|
||||
panel under the note, where they can be withdrawn; that is the only way back, since
|
||||
only the token's hash is stored and a link sent to the wrong address cannot be
|
||||
looked up.
|
||||
|
||||
### Naming somebody in a note
|
||||
|
||||
Writing `@Sofie` in a note shares it with her and tells her. Naming somebody in a
|
||||
note *is* wanting them to read it, and doing it as two acts — write the name, then
|
||||
open the panel and find the person — meant the second was forgotten, and the note
|
||||
sat there addressing somebody who could not see it. The editor suggests names after
|
||||
`@`, the same way `#` suggests projects.
|
||||
|
||||
- **Only the owner's mentions share.** An editor may write the name; giving
|
||||
somebody else's note away is the owner's alone.
|
||||
- **As a reader**, which is the least that satisfies "look at this". A role you
|
||||
have raised in the panel is never lowered by a mention.
|
||||
- **Only a name that has just appeared** counts. A note is saved on every typing
|
||||
pause, so without that every save would be another share and another notification
|
||||
about the same name.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Notifications
|
||||
|
||||
What other people did that you need to know about, and what the day has in it.
|
||||
Verdande tells you in three places at once, and they are the same thing said three
|
||||
ways: the **bell** in the header, a **push** to your devices, and the **badge** on
|
||||
the app's icon.
|
||||
|
||||
## What causes one
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
|
||||
| Somebody shared a note with you | Once, when it happens |
|
||||
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
|
||||
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
|
||||
| Somebody gave you a task | Only on the change, and only to the person who got it |
|
||||
| Somebody commented | |
|
||||
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** |
|
||||
| A new version is out | Administrators only, once per version |
|
||||
|
||||
Not your own actions: the server does not tell anybody what they just did
|
||||
themselves. And not everything that happens — a bell that rings at every change in
|
||||
a shared project is a bell people stop looking at.
|
||||
|
||||
**Repeat edits are folded.** A note is saved on every typing pause, so one person
|
||||
writing for five minutes would otherwise be twenty identical lines. While the
|
||||
message is unread, a further edit moves it back to the top and says who touched it
|
||||
last. Once you have read it, the next edit is a new thing to be told.
|
||||
|
||||
## Push, and the badge
|
||||
|
||||
**Settings → Beskeder** turns on Web Push for the device you are sitting at, per
|
||||
device: the phone in your pocket and the laptop you have open are different
|
||||
answers, and a permission granted on one is not the other's.
|
||||
|
||||
The **badge** — the number on the icon in the Dock on a Mac, on the taskbar on a
|
||||
PC — needs the app to be installed (added to the Dock, or installed as an app in
|
||||
Chrome or Edge). Half the point of being told is being told while you are doing
|
||||
something else, and the icon is the only place the app can say anything then. It
|
||||
counts unread messages and goes down by itself as you read them. Firefox does not
|
||||
support it; nothing else changes there.
|
||||
|
||||
The sentence you see is written in your browser from *what happened* and *who did
|
||||
it*, so an English interface gets an English bell. The server's own wording is
|
||||
Danish and is used only for push, which has no dictionary to look a phrase up in.
|
||||
|
||||
## Telling you about a new version
|
||||
|
||||
If the [update check](configuration.md) is on, administrators get one notification
|
||||
per version — never one a day about the same one — and it leads to
|
||||
**Settings → Beskeder**, where the button that restarts the instance to update it
|
||||
sits. With the check off, nothing is fetched and nothing is said.
|
||||
@@ -3,11 +3,11 @@
|
||||
One line in, a fully-formed task out.
|
||||
|
||||
```
|
||||
betal moms i morgen kl 10 p1 #Firma @regnskab
|
||||
file the VAT return tomorrow at 10am p1 #Accounts @tax
|
||||
```
|
||||
|
||||
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
|
||||
Firma project, labelled regnskab.
|
||||
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
|
||||
in the Accounts project, labelled tax.
|
||||
|
||||
The parts it understands are tinted underneath the text as you type, so you can see
|
||||
it being read before you commit to it.
|
||||
@@ -16,8 +16,8 @@ it being read before you commit to it.
|
||||
|
||||
| You type | It means |
|
||||
|---|---|
|
||||
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
|
||||
| `@regnskab` | Add that label. As many as you like. |
|
||||
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
|
||||
| `@tax` | Add that label. As many as you like. |
|
||||
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
|
||||
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
|
||||
| `mandag`, `på fredag` | The next such weekday. |
|
||||
|
||||
@@ -7,14 +7,14 @@ verdande ships with a Rune manifest for
|
||||
|
||||
```
|
||||
create container: Error response from daemon:
|
||||
No such image: ghcr.io/kristianwind/verdande:latest
|
||||
No such image: gitea.nolimit.dk/kw/verdande:latest
|
||||
```
|
||||
|
||||
This message is a liar. It does not mean the tag is missing from the registry —
|
||||
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||
without saying so.
|
||||
|
||||
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
||||
The usual cause is a **private** package. Yggdrasil pulls anonymously: its
|
||||
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||
looks locally — then reports the image as missing.
|
||||
@@ -24,10 +24,28 @@ looks locally — then reports the image as missing.
|
||||
sends them itself with each request. Yggdrasil is a different client and
|
||||
sends none, so logging in changes nothing for the panel.
|
||||
|
||||
**Make the package public.** Under
|
||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
||||
readable, which is what the manifest and these pages have always assumed.
|
||||
**The package has to be readable without credentials**, and the only way to know
|
||||
is to ask the registry for a token as a stranger:
|
||||
|
||||
```bash
|
||||
curl -s "https://ghcr.io/token?scope=repository:kristianwind/verdande:pull&service=ghcr.io"
|
||||
```
|
||||
|
||||
A 200 with a `token` in it means anyone can pull, which means the panel can. A
|
||||
401 means it cannot, whatever the package page says.
|
||||
|
||||
!!! danger "Do not test this with `docker --config /tmp/empty pull`"
|
||||
It looks like the honest test and is not. An empty config directory does not
|
||||
stop the machine's credential helper, so the pull is quietly authenticated
|
||||
with credentials you forgot you had — and reports success for an image no
|
||||
stranger can fetch. This cost a production instance an hour of downtime: the
|
||||
image was declared public on that evidence, the rune was pointed at it, and
|
||||
the host — which had never logged in — could not pull a thing.
|
||||
|
||||
A self-hosted Gitea is where this bites. An instance with `REQUIRE_SIGNIN_VIEW`
|
||||
turned on refuses anonymous tokens for its registry too, and no per-package
|
||||
setting overrides it. Making the source repository public does not change it
|
||||
either; that was tried.
|
||||
|
||||
Once it is public, press **Start** on the server you already have. There is no
|
||||
need to delete it and make another: the image reference is read from the rune at
|
||||
@@ -37,8 +55,8 @@ To keep the package private you have to put the image on the host yourself,
|
||||
before creating the server, and again after every release:
|
||||
|
||||
```bash
|
||||
sudo docker login ghcr.io -u kristianwind
|
||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
||||
sudo docker login gitea.nolimit.dk -u kw
|
||||
sudo docker pull gitea.nolimit.dk/kw/verdande:latest
|
||||
```
|
||||
|
||||
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||
|
||||
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 220 KiB |
@@ -1,14 +1,16 @@
|
||||
# Self-hosting
|
||||
# With Docker
|
||||
|
||||
verdande is one static binary and one directory. There is no database server, no
|
||||
cache, no queue and no second process.
|
||||
Running it yourself outside a panel: one static binary and one directory. There is
|
||||
no database server, no cache, no queue and no second process. (Yggdrasil is
|
||||
self-hosting too — for that route see [As a Rune](rune.md).)
|
||||
|
||||
## Docker
|
||||
|
||||
!!! note "If the pull is refused"
|
||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
||||
back to private, `docker login ghcr.io` with a token carrying the
|
||||
`read:packages` scope before pulling — and see
|
||||
The image is published from a self-hosted Gitea and is readable without
|
||||
credentials — the package is not linked to a repository, so its visibility
|
||||
follows the account rather than the (private) source repo. If it is ever set
|
||||
back to private, `docker login gitea.nolimit.dk` before pulling — and see
|
||||
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||
fails differently and much more confusingly under Yggdrasil.
|
||||
|
||||
|
||||
@@ -8,6 +8,38 @@ What the first account is created with. Nothing is emailed, nothing is registere
|
||||
anywhere: the first person to open a fresh instance creates the administrator
|
||||
account, and after that people arrive by invite link.
|
||||
|
||||
## Being let into a project
|
||||
|
||||
Two different situations, one panel — **the project's ⋯ menu → Share**.
|
||||
|
||||
Somebody who **already has an account here** you pick from a list by name. You do
|
||||
not need to know their email address to share a project with a colleague whose
|
||||
name you know. The accounts on an instance are a closed set — there is no open
|
||||
signup, every one of them was invited — so listing them to each other is the
|
||||
address book the feature needs, not a disclosure.
|
||||
|
||||
Somebody who is **not here yet** you invite by email. If the address turns out to
|
||||
belong to an account it becomes an ordinary share straight away; otherwise it
|
||||
becomes a link that creates their account and their membership at once. With no
|
||||
mail server configured the link is shown in the panel instead of being sent, which
|
||||
is the correct behaviour on a one-person instance rather than an error.
|
||||
|
||||
**You are told, and you can leave.** Being added to a project is not something
|
||||
that happens to you quietly: the bell says who shared what, and the entry takes
|
||||
you to the project. In the share panel, anybody who is not the owner has a *Leave
|
||||
the project* button. That is deliberate rather than an approval step — an
|
||||
invitation waiting on a yes means the person who shared thinks it is done while
|
||||
the work sits still. What was wrong was the other three-quarters of it: you were
|
||||
added without being asked, without being told, and with no way out except asking
|
||||
the owner. The owner cannot leave their own project; that is a transfer of
|
||||
ownership, and there is no transfer yet.
|
||||
|
||||
An invitation that has not been taken up stays listed under the members, greyed,
|
||||
with a × to withdraw it. Without that it is invisible — the person has no account,
|
||||
so they are neither a member nor somebody you can pick — and the usual next move
|
||||
is to send it a second time. The second link works exactly as well as the first,
|
||||
and then there are two to keep track of.
|
||||
|
||||
## A code on top of it
|
||||
|
||||
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
|
||||
|
||||
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
@@ -14,9 +16,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -45,6 +47,12 @@ type Config struct {
|
||||
// ProviderCompatible and useful for a proxy in front of the others.
|
||||
BaseURL string
|
||||
Model string
|
||||
// AllowPrivate lets BaseURL be on this machine or its private network.
|
||||
//
|
||||
// Set by the caller from who is asking, never read from storage: it is the
|
||||
// answer to "may this person reach the LAN", and that answer can change after
|
||||
// the address was saved. See the handler for who gets it and why.
|
||||
AllowPrivate bool
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
@@ -71,12 +79,31 @@ type Client struct {
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
dial := safedial.Client
|
||||
if cfg.AllowPrivate {
|
||||
dial = safedial.AllowPrivate
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
// Generous: a local model on modest hardware can take a while to produce
|
||||
// its first token, and the alternative is a timeout that only ever fires
|
||||
// for the people running their own.
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
// safedial by default. `base_url` is typed into a field in the interface,
|
||||
// so it is a user-supplied address — and a server that fetches a
|
||||
// user-supplied address is a way to ask it to reach things the caller
|
||||
// cannot: the panel next door, a database on the same bridge, the cloud
|
||||
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||
//
|
||||
// The check is on the resolved address rather than on the URL, because a
|
||||
// name answers whatever its owner says — and can answer differently the
|
||||
// second time, after a parse-time check has passed.
|
||||
//
|
||||
// And then the exception, which is the whole reason this provider exists:
|
||||
// a model you run yourself IS on that network. Blocking it made the two
|
||||
// lines above true and the feature useless — the comment about a local
|
||||
// model being slow to its first token was written for a case the dialler
|
||||
// refused to reach. AllowPrivate is granted per request, not per address.
|
||||
http: dial(120 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +295,35 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
// The provider's own message is included: for a local model it is usually
|
||||
// the actual problem ("model not found"), and hiding it would leave the
|
||||
// operator with nothing to go on.
|
||||
return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
|
||||
// The provider's body is no longer passed back to the caller.
|
||||
//
|
||||
// It was, and combined with a free-text base URL that made a working
|
||||
// internal scanner: point it at a host, read the first three hundred bytes
|
||||
// of whatever answered. The operator still needs the message — for a local
|
||||
// model it is usually the actual problem — so it goes to the log, where it
|
||||
// is available to the person running the instance and to nobody else.
|
||||
return &upstreamError{Host: req.URL.Host, Status: resp.Status, Body: truncate(string(raw), 300)}
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
// upstreamError keeps the provider's own words for the log and away from the
|
||||
// response. Error() is what reaches a caller; Detail() is what is written down.
|
||||
type upstreamError struct {
|
||||
Host string
|
||||
Status string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *upstreamError) Error() string {
|
||||
return fmt.Sprintf("ai: %s said %s", e.Host, e.Status)
|
||||
}
|
||||
|
||||
// Detail is the whole of it, for the instance's own error log.
|
||||
func (e *upstreamError) Detail() string {
|
||||
return fmt.Sprintf("ai: %s said %s: %s", e.Host, e.Status, e.Body)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
@@ -344,22 +395,266 @@ back, do not congratulate, and do not offer productivity advice.`, language)
|
||||
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
}
|
||||
|
||||
// Suggestion er én foreslået opgave, skrevet som en linje, man selv kunne have
|
||||
// tastet.
|
||||
//
|
||||
// `line` er quick-add-syntaks — "Ring til Anders i morgen p1 #Firma" — og ikke et
|
||||
// felt pr. egenskab. To grunde, og den anden er den vigtige. Parseren findes
|
||||
// allerede og er den samme, uanset hvor teksten kom fra, så der er ingen anden
|
||||
// fortolkning at holde i takt med den. Og et forslag, der er skrevet i det sprog,
|
||||
// man selv skriver i, kan læses og rettes af den, det bliver stillet til — hvor
|
||||
// et sæt felter, en model har udfyldt, er noget, man skal tage eller lade være.
|
||||
//
|
||||
// `why` er én sætning om, hvorfor den ser sådan ud. Den står ved siden af
|
||||
// forslaget, så et forkert gæt kan gennemskues frem for bare at være forkert.
|
||||
type Suggestion struct {
|
||||
Line string `json:"line"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// TidyInbox foreslår, hvordan løse opgaver i indbakken kunne skrives færdig.
|
||||
//
|
||||
// Modellen får de projekter, der findes, og må kun bruge dem: et `#Nyt Projekt`,
|
||||
// den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja.
|
||||
func (c *Client) TidyInbox(ctx context.Context, tasks, projects []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
known := "none"
|
||||
if len(projects) > 0 {
|
||||
known = "#" + strings.Join(projects, ", #")
|
||||
}
|
||||
|
||||
system := fmt.Sprintf(`You tidy up a task inbox.
|
||||
|
||||
For each task you are given, write the line the person would have typed if they
|
||||
had filed it properly. Keep their own words for what the task is — you are filing
|
||||
it, not rewriting it.
|
||||
|
||||
The line may carry, in this syntax and no other:
|
||||
#Project one of these existing projects, and never one that is not: %s
|
||||
p1..p4 priority, where p1 is most urgent
|
||||
a date in plain %s, such as "i morgen", "on friday", "15/3"
|
||||
|
||||
Leave out anything the task does not tell you. A guessed deadline is worse than
|
||||
none: the person can add one, but they cannot see a wrong one you invented.
|
||||
Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "...", "why": "..."} where why is at most one short sentence in %s
|
||||
saying what you read out of the task. Give one element per task, in the order you
|
||||
were given them, and leave a task out entirely if it is already filed well.`,
|
||||
known, language, today, language)
|
||||
|
||||
prompt := "Tasks:\n" + strings.Join(tasks, "\n")
|
||||
return c.suggestions(ctx, system, prompt)
|
||||
}
|
||||
|
||||
// ActionsInNote trækker det, nogen har lovet, ud af en note.
|
||||
//
|
||||
// Beskrivelsen er stram med vilje. En model, der får lov at "finde opgaver" i en
|
||||
// tekst, finder også emner, overskrifter og gode idéer — og så er resultatet en
|
||||
// liste, man skal luge i, hvilket er dyrere end at skrive de tre punkter selv.
|
||||
func (c *Client) ActionsInNote(ctx context.Context, note string, projects []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
known := "none"
|
||||
if len(projects) > 0 {
|
||||
known = "#" + strings.Join(projects, ", #")
|
||||
}
|
||||
|
||||
system := fmt.Sprintf(`You read a note and pull out what somebody has to do.
|
||||
|
||||
Only things somebody committed to or was asked to do. A heading is not a task, a
|
||||
topic that was discussed is not a task, and an idea nobody agreed to is not a
|
||||
task. If the note contains none, reply with an empty array — that is a useful
|
||||
answer and a made-up list is not.
|
||||
|
||||
Write each one as the line the person would have typed, in %s, in this syntax and
|
||||
no other:
|
||||
#Project one of these existing projects, and never one that is not: %s
|
||||
p1..p4 priority, where p1 is most urgent
|
||||
a date in plain %s, only when the note actually gives one
|
||||
|
||||
Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "...", "why": "..."} where why quotes the few words in the note that
|
||||
the task came from, so it can be checked.`, language, known, language, today)
|
||||
|
||||
return c.suggestions(ctx, system, "Note:\n"+note)
|
||||
}
|
||||
|
||||
// DailyPlan skriver morgenens to-tre linjer.
|
||||
//
|
||||
// Tallene er talt i basen og gives med som kendsgerninger, modellen ikke må regne
|
||||
// om. En plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan —
|
||||
// og en model, der tæller en liste, tæller den forkert af og til.
|
||||
//
|
||||
// Det ene sted, den må mene noget, er hvad man skulle begynde med, og hvad der
|
||||
// kunne vente. Det er dét, der gør det til en plan frem for en optælling.
|
||||
func (c *Client) DailyPlan(ctx context.Context, tasks []string, due, overdue int, locale string) (string, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You write somebody's morning note about their own day.
|
||||
|
||||
Write in %s. At most three short lines, no greeting, no sign-off, no
|
||||
encouragement. Say what to start with and why. Name at most one thing that could
|
||||
wait or be dropped, and only if something obviously can.
|
||||
|
||||
These numbers are counted for you and are facts: %d due today, %d overdue. Do not
|
||||
recount them, do not contradict them, and do not list every task back.`,
|
||||
language, due, overdue)
|
||||
|
||||
return c.Complete(ctx, system, []Message{
|
||||
{Role: "user", Content: "The tasks:\n" + strings.Join(tasks, "\n")},
|
||||
})
|
||||
}
|
||||
|
||||
// Ask svarer på et spørgsmål ud fra de tekststykker, den får med — og kun dem.
|
||||
//
|
||||
// Svaret skal bære sine kilder, og det er derfor svaret er JSON og ikke prosa: en
|
||||
// model, der skriver "[3]" midt i en sætning, gør det af og til, og en, der
|
||||
// afleverer nummeret i et felt, gør det hver gang. Fladen skal kunne lave et link,
|
||||
// ikke lede efter kantede parenteser i en tekst.
|
||||
//
|
||||
// "Det står der ikke" er et rigtigt svar. En model, der hellere gætter end
|
||||
// skuffer, er ubrugelig til netop det her: man spørger om sine egne noter, fordi
|
||||
// man ikke selv kan huske det, og har derfor ingen mulighed for at se, at svaret
|
||||
// var opfundet.
|
||||
func (c *Client) Ask(ctx context.Context, question string, passages []string, locale string) (string, []int, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You answer a question from somebody's own notes and tasks.
|
||||
|
||||
Use only the passages given. If they do not answer the question, say so plainly —
|
||||
that is a useful answer, and a guess is not: the person is asking because they
|
||||
cannot remember, so they cannot tell an invented answer from a real one.
|
||||
|
||||
Write in %s, at most four short lines. Reply with a JSON object and nothing else:
|
||||
{"answer": "...", "sources": [1, 3]} where sources are the numbers of the
|
||||
passages the answer actually rests on, and nothing else.`, language)
|
||||
|
||||
prompt := "Question: " + question + "\n\nPassages:\n" + strings.Join(passages, "\n\n")
|
||||
|
||||
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Answer string `json:"answer"`
|
||||
Sources []int `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractObject(reply)), &out); err != nil {
|
||||
// Et svar, der ikke er JSON, er stadig et svar. Teksten er det, personen
|
||||
// spurgte om; henvisningerne er det, der går tabt, og det er en dårligere
|
||||
// dag end en fejlmeddelelse ville være.
|
||||
return strings.TrimSpace(reply), nil, nil
|
||||
}
|
||||
return strings.TrimSpace(out.Answer), out.Sources, nil
|
||||
}
|
||||
|
||||
// TasksFromMail skriver én opgavelinje pr. mail.
|
||||
//
|
||||
// Emnefeltet er skrevet til at blive genkendt i en indbakke, ikke til at blive
|
||||
// gjort noget ved: "SV: SV: Vedr. levering uge 12" siger, hvilken tråd det er, og
|
||||
// intet om, hvad man skal. Modellen skriver den handling, mailen beder om — og
|
||||
// lader være, hvis den ikke beder om nogen.
|
||||
func (c *Client) TasksFromMail(ctx context.Context, mails []string, today, locale string) ([]Suggestion, error) {
|
||||
language := languageOf(locale)
|
||||
|
||||
system := fmt.Sprintf(`You turn emails into the tasks they ask for.
|
||||
|
||||
For each numbered email, write the line the recipient would have typed for
|
||||
themselves — what they have to DO, in %s, starting with a verb. Keep the sender's
|
||||
name in it when it matters who it is about. Not a summary of the email: the
|
||||
action it asks for.
|
||||
|
||||
The line may carry a date in plain %s, and p1..p4 for priority, but only when the
|
||||
email itself says so — "before friday" in the text is a date, an email that merely
|
||||
sounds urgent is not. Today is %s.
|
||||
|
||||
Reply with a JSON array and nothing else — no prose, no code fence. Each element
|
||||
is {"line": "N. ..."} keeping the number the email was given, so the line can be
|
||||
put back on the right one. Leave an email out entirely if it asks for nothing.`,
|
||||
language, language, today)
|
||||
|
||||
return c.suggestions(ctx, system, "Emails:\n"+strings.Join(mails, "\n"))
|
||||
}
|
||||
|
||||
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
|
||||
// der ikke er en linje.
|
||||
//
|
||||
// Et tomt svar er ikke en fejl. "Der er ikke noget at foreslå" er et rigtigt svar
|
||||
// på begge spørgsmål, og en fejlmeddelelse ville lære folk, at funktionen er i
|
||||
// stykker, når den i virkeligheden var enig med dem.
|
||||
func (c *Client) suggestions(ctx context.Context, system, prompt string) ([]Suggestion, error) {
|
||||
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Suggestion
|
||||
if err := json.Unmarshal([]byte(extractJSON(reply)), &out); err != nil {
|
||||
return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
|
||||
}
|
||||
|
||||
clean := make([]Suggestion, 0, len(out))
|
||||
for _, s := range out {
|
||||
s.Line = strings.TrimSpace(s.Line)
|
||||
s.Why = strings.TrimSpace(s.Why)
|
||||
if s.Line != "" {
|
||||
clean = append(clean, s)
|
||||
}
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// languageOf er det sprog, svaret skal skrives i. Ét sted, fordi det ellers er
|
||||
// den slags, der bliver rettet ét sted ud af fire.
|
||||
func languageOf(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "English"
|
||||
}
|
||||
return "Danish"
|
||||
}
|
||||
|
||||
// unfence tager svaret ud af den kodeblok, modeller pakker det ind i, uanset hvor
|
||||
// omhyggeligt de bliver bedt om at lade være.
|
||||
func unfence(reply string) string {
|
||||
reply = strings.TrimSpace(reply)
|
||||
fence := strings.Index(reply, "```")
|
||||
if fence < 0 {
|
||||
return reply
|
||||
}
|
||||
rest := reply[fence+3:]
|
||||
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
|
||||
rest = rest[newline+1:]
|
||||
}
|
||||
if end := strings.Index(rest, "```"); end >= 0 {
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// extractObject er extractJSON for et objekt frem for en liste. Samme problem,
|
||||
// samme løsning: modeller pakker svar ind i en kodeblok, hvor omhyggeligt de end
|
||||
// bliver bedt om at lade være.
|
||||
func extractObject(reply string) string {
|
||||
reply = unfence(reply)
|
||||
start := strings.IndexByte(reply, '{')
|
||||
end := strings.LastIndexByte(reply, '}')
|
||||
if start >= 0 && end > start {
|
||||
return reply[start : end+1]
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// extractJSON pulls an array out of a reply that may have been wrapped in a code
|
||||
// fence or preceded by a sentence, which models do however firmly they are asked
|
||||
// not to.
|
||||
func extractJSON(reply string) string {
|
||||
reply = strings.TrimSpace(reply)
|
||||
|
||||
if fence := strings.Index(reply, "```"); fence >= 0 {
|
||||
rest := reply[fence+3:]
|
||||
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
|
||||
rest = rest[newline+1:]
|
||||
}
|
||||
if end := strings.Index(rest, "```"); end >= 0 {
|
||||
reply = strings.TrimSpace(rest[:end])
|
||||
}
|
||||
}
|
||||
|
||||
reply = unfence(reply)
|
||||
start := strings.IndexByte(reply, '[')
|
||||
end := strings.LastIndexByte(reply, ']')
|
||||
if start >= 0 && end > start {
|
||||
|
||||
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TOTPStep returns the step a code matched, and it must be the same step across
|
||||
// the tolerance window — that number is what makes a code single-use, so a code
|
||||
// entered a little early or late has to spend the same step, not a new one.
|
||||
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
|
||||
secret, _, err := NewTOTPSecret("verdande", "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
|
||||
code := codeAt(t, secret, now)
|
||||
|
||||
want, err := TOTPStep(secret, code, now)
|
||||
if err != nil {
|
||||
t.Fatalf("the current code did not match: %v", err)
|
||||
}
|
||||
// Verified one step late — the code is still valid, and must report the step it
|
||||
// was issued at, not the step it was checked at.
|
||||
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("code rejected one step late: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
|
||||
}
|
||||
|
||||
if _, err := TOTPStep(secret, "000000", now); err == nil {
|
||||
t.Error("a wrong code returned a step")
|
||||
}
|
||||
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
|
||||
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
|
||||
}
|
||||
}
|
||||
|
||||
// codeAt produces the code an authenticator app would display at that moment,
|
||||
// using the same settings VerifyTOTP checks against.
|
||||
func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boombuler/barcode/qr"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
@@ -49,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
|
||||
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
|
||||
// base64 blob to the page — one dark path on a white square, which is what a scanner
|
||||
// needs whatever theme the page is in.
|
||||
//
|
||||
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
|
||||
// this borrows a dependency rather than adding one.
|
||||
func TOTPQRSVG(uri string) (string, error) {
|
||||
code, err := qr.Encode(uri, qr.M, qr.Auto)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: encode totp qr: %w", err)
|
||||
}
|
||||
b := code.Bounds()
|
||||
n := b.Dx()
|
||||
// A four-module quiet zone all round, which the spec requires and scanners rely
|
||||
// on — without it the code runs to the edge and a reader cannot find its border.
|
||||
const quiet = 4
|
||||
size := n + quiet*2
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
|
||||
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
|
||||
for y := 0; y < n; y++ {
|
||||
for x := 0; x < n; x++ {
|
||||
// Dark modules are black; the red channel is 0 for those and full for the
|
||||
// light ones. One 1×1 rect per dark module, merged into a single path.
|
||||
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
|
||||
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString(`"/></svg>`)
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||
// code again before enrolment is confirmed.
|
||||
func TOTPURI(secret, issuer, account string) string {
|
||||
@@ -82,6 +119,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TOTPStep verifies a code and returns the counter of the step it matched, so the
|
||||
// caller can refuse to accept the same step twice.
|
||||
//
|
||||
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
|
||||
// window is not enough to stop a replay: a code seen in flight stays valid for the
|
||||
// rest of its period, and the fix is to record which step was spent and never
|
||||
// accept that step or an earlier one again. That needs the number, so this walks
|
||||
// the same window ValidateCustom would — one step either side — and reports the
|
||||
// counter of the match. The comparison is constant time for the same reason the
|
||||
// password check is: a code is a secret being checked against a computed value.
|
||||
func TOTPStep(secret, code string, now time.Time) (int64, error) {
|
||||
if secret == "" {
|
||||
return 0, ErrNoTOTPSecret
|
||||
}
|
||||
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||
|
||||
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
|
||||
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
|
||||
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
|
||||
Period: totpPeriod,
|
||||
Skew: 0,
|
||||
Digits: totpDigits,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||
return at.Unix() / totpPeriod, nil
|
||||
}
|
||||
}
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
|
||||
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
||||
// and the hashes to store. Without these, losing a device means losing the account,
|
||||
// and the only fix left is editing the database by hand.
|
||||
|
||||
@@ -7,6 +7,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -80,6 +81,26 @@ type Config struct {
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
// TrustedProxies are the peers whose forwarded-for header may be believed.
|
||||
//
|
||||
// The rate limiter and the audit log key on the caller's address, and that
|
||||
// address is only as honest as wherever it was read. Read it from a header that
|
||||
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
|
||||
// every request and to write someone else's IP into the log. So the header is
|
||||
// believed only when the machine that *connected* is one of these — a reverse
|
||||
// proxy or tunnel the operator put there — and ignored when the connection came
|
||||
// straight off the internet.
|
||||
//
|
||||
// The default is "the peer is on a private network", which is exactly the shape
|
||||
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
|
||||
// host or the docker bridge, so its address is loopback or RFC1918. A directly
|
||||
// exposed instance sees a public peer, trusts nothing, and keys on the real
|
||||
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
|
||||
// or "none" to trust no proxy at all.
|
||||
TrustedProxies []netip.Prefix
|
||||
// RealIPHeader is where the client address is read from, for a trusted peer.
|
||||
RealIPHeader string
|
||||
|
||||
Dev bool
|
||||
}
|
||||
|
||||
@@ -149,9 +170,70 @@ func Load() (*Config, error) {
|
||||
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
|
||||
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
|
||||
// families' link-local. A proxy an operator stands up in front of this server is
|
||||
// on one of these; a stranger connecting directly is on none.
|
||||
var defaultTrustedProxies = mustPrefixes(
|
||||
"127.0.0.0/8", "::1/128",
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
"fc00::/7",
|
||||
"169.254.0.0/16", "fe80::/10",
|
||||
)
|
||||
|
||||
// parseTrustedProxies reads the override. Empty keeps the private-network default;
|
||||
// the literal "none" trusts no proxy at all, for an instance that is exposed
|
||||
// directly and must never believe a forwarded header.
|
||||
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultTrustedProxies, nil
|
||||
}
|
||||
if strings.EqualFold(raw, "none") {
|
||||
return nil, nil
|
||||
}
|
||||
var out []netip.Prefix
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
|
||||
// naming one proxy does not have to know CIDR notation to do it.
|
||||
if !strings.Contains(part, "/") {
|
||||
addr, err := netip.ParseAddr(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
|
||||
}
|
||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
continue
|
||||
}
|
||||
p, err := netip.ParsePrefix(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
|
||||
}
|
||||
out = append(out, p.Masked())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustPrefixes(cidrs ...string) []netip.Prefix {
|
||||
out := make([]netip.Prefix, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
out = append(out, netip.MustParsePrefix(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
|
||||
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
||||
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
||||
|
||||
@@ -60,6 +60,20 @@ func Parse(query string, ctx Context) (Compiled, error) {
|
||||
if len(p.tokens) == 0 {
|
||||
return Compiled{}, fmt.Errorf("filteret er tomt")
|
||||
}
|
||||
// Refused before it is parsed, not while.
|
||||
//
|
||||
// `combine` rebuilds the whole accumulated SQL string and re-copies the
|
||||
// argument slice per operator, so the cost is quadratic in the number of
|
||||
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
|
||||
// query string — took seven seconds of CPU and forty-six gigabytes of
|
||||
// allocation in one request. The preview endpoint runs this on whatever is
|
||||
// typed, and there is no write timeout on the server to cut it off.
|
||||
//
|
||||
// Two hundred terms is a filter nobody has ever written and far below where it
|
||||
// costs anything.
|
||||
if len(p.tokens) > maxTokens {
|
||||
return Compiled{}, fmt.Errorf("filteret er for langt")
|
||||
}
|
||||
|
||||
node, err := p.parseExpression()
|
||||
if err != nil {
|
||||
@@ -140,10 +154,19 @@ func isOperator(r rune) bool {
|
||||
|
||||
// --- parser -------------------------------------------------------------------
|
||||
|
||||
// The ceilings on one filter. See Parse for why.
|
||||
const (
|
||||
maxTokens = 200
|
||||
maxDepth = 32
|
||||
)
|
||||
|
||||
type parser struct {
|
||||
tokens []token
|
||||
pos int
|
||||
ctx Context
|
||||
// depth guards against a filter that is only parentheses. Each level is a
|
||||
// stack frame, and nothing else stops somebody sending a megabyte of them.
|
||||
depth int
|
||||
}
|
||||
|
||||
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
|
||||
@@ -187,6 +210,13 @@ func (p *parser) parseTerm() (Compiled, error) {
|
||||
if p.done() {
|
||||
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
|
||||
}
|
||||
// Nesting is stack depth. A filter made of half a million opening parentheses
|
||||
// is a megabyte of text and a stack frame for each.
|
||||
p.depth++
|
||||
defer func() { p.depth-- }()
|
||||
if p.depth > maxDepth {
|
||||
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
|
||||
}
|
||||
|
||||
switch t := p.peek(); t.kind {
|
||||
case tokNot:
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||
// it asks for.
|
||||
//
|
||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
||||
// message does not delete the task, completing the task does not unstar the
|
||||
// message. Two-way would mean deciding what to do when both sides changed, and the
|
||||
// only honest answer to that is a synchronisation model far bigger than the feature.
|
||||
// The one thing verdande writes back is the star. A starred message becomes a
|
||||
// task, and once it has, the star comes off — exactly as the IMAP side takes the
|
||||
// flag off a mail it has imported, and for the same reason: the star meant "turn
|
||||
// this into a task", the task now exists, so the star is no longer true. Nothing
|
||||
// else is touched. Completing the task does not reach back to the mail; the mail
|
||||
// is a pointer the task was made from, not a copy the two have to be kept in step.
|
||||
//
|
||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
||||
// asking for less is what makes the consent screen something a person can agree to.
|
||||
// That one write is why the scope is gmail.modify rather than gmail.readonly.
|
||||
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
|
||||
// can do it — so the consent screen asks for more than it used to, and the trade
|
||||
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
|
||||
// than one that fills with stars nothing ever clears. Still not send, still not
|
||||
// delete — modify is labels, and the only label verdande removes is the one that
|
||||
// triggered the task.
|
||||
//
|
||||
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
||||
// The names below are aliases so that every call site — and this package's own
|
||||
@@ -27,8 +33,10 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
// Read mail and change its labels, and only mail. Not profile, not contacts, not
|
||||
// send, not delete. The single write is removing the STARRED label from a message
|
||||
// that has become a task; see the package comment for why that costs modify.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.modify"
|
||||
|
||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
@@ -169,6 +177,25 @@ func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
|
||||
// Unstar takes the star off the given messages — one call for all of them, the way
|
||||
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
|
||||
// not ten.
|
||||
//
|
||||
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
|
||||
// label would need one, and verdande removes none. batchModify answers 204 with no
|
||||
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
|
||||
// an error on Google's side and nothing worth making a round trip to hear.
|
||||
func (c *Client) Unstar(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
body := struct {
|
||||
IDs []string `json:"ids"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
|
||||
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
|
||||
}
|
||||
|
||||
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
|
||||
// falling back to the address when there is no display name.
|
||||
func SenderName(from string) string {
|
||||
|
||||
@@ -1,16 +1,81 @@
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||
// one the person reads on the consent screen. The flow itself is exercised in
|
||||
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
||||
func TestTheScopeIsReadOnlyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
||||
// internal/google; what belongs here is that Gmail asks for modify — read plus the
|
||||
// one write it makes, taking the star off an imported message — and nothing wider.
|
||||
func TestTheScopeIsModifyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.modify") {
|
||||
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
|
||||
}
|
||||
// Modify is the ceiling. Send, or the full account, would be a different promise
|
||||
// to the person on the consent screen than the one this feature needs.
|
||||
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
|
||||
if strings.Contains(Scope, wider) {
|
||||
t.Errorf("scope = %q; must not include %q", Scope, wider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unstar removes the star from a batch in one call, and it addresses the messages
|
||||
// it was given and only the STARRED label.
|
||||
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody struct {
|
||||
IDs []string `json:"ids"`
|
||||
AddLabelIDs []string `json:"addLabelIds"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
|
||||
if err != nil {
|
||||
t.Fatalf("Unstar: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
|
||||
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
|
||||
}
|
||||
if strings.Join(gotBody.IDs, ",") != "a,b" {
|
||||
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
|
||||
}
|
||||
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
|
||||
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
|
||||
}
|
||||
if len(gotBody.AddLabelIDs) != 0 {
|
||||
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
|
||||
// there is nothing to say to it anyway.
|
||||
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
|
||||
t.Fatalf("Unstar(nil): %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("Unstar(nil) made a request; it should make none")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,40 @@ func Get(ctx context.Context, client *http.Client, accessToken, url string, out
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
|
||||
// body coming back. A write call — modifying labels on a message — answers with
|
||||
// 204 No Content when it worked, so anything 2xx is success and only the body is
|
||||
// decoded, and only when the caller asked for one.
|
||||
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("google: %s", resp.Status)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog.
|
||||
//
|
||||
// Søgningen finder det, der indeholder ordene. Det her svarer på spørgsmålet —
|
||||
// "hvad lovede jeg Anders i august?" — og det er ikke det samme: svaret står
|
||||
// spredt over tre noter og en opgave, og ingen af dem indeholder ordet "lovede".
|
||||
//
|
||||
// To halvdele, og rækkefølgen er hele sikkerheden i det. Basen finder kandidaterne
|
||||
// med den søgning, der allerede findes, og som allerede kun kan se det, personen
|
||||
// selv må se. Modellen får kun dem — den slår ikke selv noget op og kan ikke bede
|
||||
// om mere. Et svar, der ikke står i det udleverede, kan derfor ikke handle om
|
||||
// nogen andens noter.
|
||||
//
|
||||
// Og svaret bærer, hvad det er bygget på. En model, der svarer "det gjorde du den
|
||||
// 14.", er kun brugbar, hvis man kan slå op i den note, den læste det i.
|
||||
|
||||
type askRequest struct {
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
type askSourceJSON struct {
|
||||
Kind string `json:"kind"` // "note" eller "task"
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// handleAIAsk svarer på et spørgsmål ud fra personens egne noter og opgaver.
|
||||
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
|
||||
var req askRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
question := strings.TrimSpace(req.Question)
|
||||
if question == "" {
|
||||
writeFieldErrors(w, map[string]string{"question": "required"})
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Ord for ord, ikke hele spørgsmålet på én gang.
|
||||
//
|
||||
// Søgningen kræver, at *alle* ord optræder, og det er den rigtige regel for et
|
||||
// søgefelt: skriver man to ord, mener man begge. Men et spørgsmål er ikke en
|
||||
// søgning — "hvad lovede jeg Anders i august" indeholder ét ord, der står i
|
||||
// noten, og fem, der ikke gør, så et krav om dem alle finder ingenting hver
|
||||
// eneste gang.
|
||||
//
|
||||
// Så: et groft net her, og modellen læser fangsten. Alternativet — at lade
|
||||
// modellen skrive søgningen — lægger et led mere ind, der kan tage fejl,
|
||||
// mellem spørgsmålet og de noter, der faktisk findes.
|
||||
notes, tasks, err := s.candidatesFor(r, user.ID, question)
|
||||
if err != nil {
|
||||
s.internal(w, r, "search for the question", err)
|
||||
return
|
||||
}
|
||||
if len(notes) == 0 && len(tasks) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"answer": "", "sources": []askSourceJSON{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Nummereret, fordi svaret skal kunne pege tilbage. En henvisning til "noten
|
||||
// om leveringen" er ikke noget, fladen kan lave et link ud af; et nummer er.
|
||||
sources := make([]askSourceJSON, 0, len(notes)+len(tasks))
|
||||
passages := make([]string, 0, len(notes)+len(tasks))
|
||||
for _, n := range notes {
|
||||
sources = append(sources, askSourceJSON{Kind: "note", ID: n.ID, Title: n.Title})
|
||||
passages = append(passages, "["+itoaSource(len(sources))+"] Note: "+n.Title+"\n"+
|
||||
firstWords(n.Body, 300))
|
||||
}
|
||||
for _, t := range tasks {
|
||||
sources = append(sources, askSourceJSON{Kind: "task", ID: t.ID, Title: t.Content})
|
||||
line := "[" + itoaSource(len(sources)) + "] Task: " + t.Content
|
||||
if t.DueDate != "" {
|
||||
line += " (" + t.DueDate + ")"
|
||||
}
|
||||
if t.Description != "" {
|
||||
line += "\n" + firstWords(t.Description, 60)
|
||||
}
|
||||
passages = append(passages, line)
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
answer, used, err := client.Ask(ctx, question, passages, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai ask", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Kun de kilder, svaret faktisk brugte. Alle tolv ville gøre henvisningen
|
||||
// værdiløs: en liste over alt, der blev kigget i, er ikke det samme som det,
|
||||
// svaret står på.
|
||||
cited := make([]askSourceJSON, 0, len(used))
|
||||
for _, n := range used {
|
||||
if n >= 1 && n <= len(sources) {
|
||||
cited = append(cited, sources[n-1])
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"answer": answer, "sources": cited})
|
||||
}
|
||||
|
||||
func itoaSource(n int) string {
|
||||
if n < 10 {
|
||||
return string(rune('0' + n))
|
||||
}
|
||||
return string(rune('0'+n/10)) + string(rune('0'+n%10))
|
||||
}
|
||||
|
||||
// candidatesFor henter det, spørgsmålet kunne handle om.
|
||||
//
|
||||
// Ét opslag pr. ord, lagt sammen og med det, der rammes af flere ord, øverst: et
|
||||
// spørgsmål har ét eller to bærende ord og en håndfuld, der bare er dansk, og den
|
||||
// note, der indeholder begge de bærende, er den, det handler om.
|
||||
//
|
||||
// Korte ord springes over. "i", "på" og "the" står i alt, og en note, der blev
|
||||
// hentet, fordi den indeholder "i", fortrænger en, der faktisk svarer.
|
||||
func (s *Server) candidatesFor(r *http.Request, userID, question string) ([]store.Note, []store.Task, error) {
|
||||
terms := store.SearchTerms(question)
|
||||
|
||||
type scored struct {
|
||||
note store.Note
|
||||
task store.Task
|
||||
hits int
|
||||
}
|
||||
noteHits := map[string]*scored{}
|
||||
taskHits := map[string]*scored{}
|
||||
|
||||
for _, term := range terms {
|
||||
if len([]rune(term)) < 3 {
|
||||
continue
|
||||
}
|
||||
found, err := s.db.SearchNotes(r.Context(), term, 10)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, n := range found {
|
||||
if at, ok := noteHits[n.ID]; ok {
|
||||
at.hits++
|
||||
continue
|
||||
}
|
||||
noteHits[n.ID] = &scored{note: n, hits: 1}
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), userID, store.TaskFilter{
|
||||
Search: term, Limit: 10, IncludeCompleted: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if at, ok := taskHits[t.ID]; ok {
|
||||
at.hits++
|
||||
continue
|
||||
}
|
||||
taskHits[t.ID] = &scored{task: t, hits: 1}
|
||||
}
|
||||
}
|
||||
|
||||
notes := make([]store.Note, 0, len(noteHits))
|
||||
for _, at := range noteHits {
|
||||
notes = append(notes, at.note)
|
||||
}
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
return noteHits[notes[i].ID].hits > noteHits[notes[j].ID].hits
|
||||
})
|
||||
|
||||
tasks := make([]store.Task, 0, len(taskHits))
|
||||
for _, at := range taskHits {
|
||||
tasks = append(tasks, at.task)
|
||||
}
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
return taskHits[tasks[i].ID].hits > taskHits[tasks[j].ID].hits
|
||||
})
|
||||
|
||||
// Loftet er, hvad der kan stå i én prompt uden at koste mere, end svaret er
|
||||
// værd — og hvad et menneske ville have gidet læse igennem selv.
|
||||
return capped(notes, 12), capped(tasks, 20), nil
|
||||
}
|
||||
|
||||
func capped[T any](in []T, n int) []T {
|
||||
if len(in) > n {
|
||||
return in[:n]
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// En mail, der bliver til en brugbar opgave.
|
||||
//
|
||||
// Uden det her hedder opgaven, hvad mailen hed: "Anders Jensen: SV: SV: Vedr.
|
||||
// levering uge 12". Det er nok til at genkende den og ikke nok til at gøre noget
|
||||
// ved den — og en indbakke af den slags linjer er præcis den bunke, man havde
|
||||
// mail for at slippe for.
|
||||
//
|
||||
// Ét kald pr. gennemløb frem for ét pr. mail. Femogtyve mails er femogtyve gange
|
||||
// ventetid og femogtyve gange betaling for det samme spørgsmål, og et gennemløb,
|
||||
// der tager fem minutter, er et, der overlapper med det næste.
|
||||
//
|
||||
// Falder det ud, er der ikke sket noget: opgaven hedder afsender og emne, som den
|
||||
// altid har gjort. En model, der er nede, må ikke kunne stoppe posten.
|
||||
|
||||
// mailForAI er én mail, som modellen får den at se.
|
||||
type mailForAI struct {
|
||||
From string
|
||||
Subject string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
// mailTaskLines beder modellen om én linje pr. mail, i quick-add-syntaks.
|
||||
//
|
||||
// Returnerer en tabel fra mailens plads i listen til linjen. Ikke en liste, fordi
|
||||
// en model, der springer en mail over, ellers ville flytte alle de følgende
|
||||
// linjer én plads — og så ville en opgave hedde noget, der stod i en anden mail.
|
||||
// Det er den samme forsigtighed som ved oprydningen i indbakken, og den samme
|
||||
// grund: en forskydning på én er den slags fejl, ingen opdager.
|
||||
func (s *Server) mailTaskLines(ctx context.Context, user *store.User, mails []mailForAI) map[int]string {
|
||||
if len(mails) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg, err := s.aiConfigFor(ctx, user)
|
||||
if err != nil || !cfg.Configured() {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(mails))
|
||||
for i, m := range mails {
|
||||
line := strconv.Itoa(i+1) + ". " + m.From + " — " + m.Subject
|
||||
if m.Snippet != "" {
|
||||
line += "\n " + firstWords(m.Snippet, 40)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
out, err := ai.New(cfg).TasksFromMail(ctx, lines, today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Info("mail tasks without the model", "err", err, "user", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
byIndex := map[int]string{}
|
||||
for i, sug := range out {
|
||||
n, line := numberedLine(sug.Line)
|
||||
if n == 0 {
|
||||
n = i + 1
|
||||
}
|
||||
if n < 1 || n > len(mails) || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
byIndex[n-1] = line
|
||||
}
|
||||
return byIndex
|
||||
}
|
||||
|
||||
// applyMailLine lægger modellens linje ind i opgaven, hvis der er en.
|
||||
//
|
||||
// Linjen går gennem quick-add-parseren som alt andet, så en mail, hvor der står
|
||||
// "inden fredag", får en dato — og "#Firma" i en linje, modellen fandt på, flytter
|
||||
// ingenting, for projektet slås op blandt dem, der findes.
|
||||
//
|
||||
// Emnet bliver stående i beskrivelsen. Modellen har skrevet en titel ud fra det,
|
||||
// og hvis den har misforstået mailen, skal det oprindelige stadig kunne læses ved
|
||||
// siden af — ellers er den eneste vej tilbage at finde mailen frem igen.
|
||||
func (s *Server) applyMailLine(ctx context.Context, user *store.User, task *store.Task, line, subject string) {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return
|
||||
}
|
||||
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
task.Content = parsed.Content
|
||||
if parsed.Priority != 0 {
|
||||
task.Priority = parsed.Priority
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
task.DueDate = parsed.DueDate
|
||||
}
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(ctx, user.ID, parsed.Project); err == nil {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||
task.ProjectID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject != "" {
|
||||
task.Description = strings.TrimSpace(subject + "\n\n" + task.Description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Dagens plan: én besked om morgenen om det, der venter i dag.
|
||||
//
|
||||
// Klokken siger, hvad andre har gjort. Det her siger, hvad der ligger foran en —
|
||||
// og det er en anden slags besked: den kommer, fordi det er blevet morgen, ikke
|
||||
// fordi nogen har rørt noget. Uden den skal man selv åbne listen for at opdage,
|
||||
// at der lå tre ting og forfaldt i går.
|
||||
//
|
||||
// Sendt som en besked frem for en mail, fordi røret allerede findes: klokken,
|
||||
// push til telefonen og mærket på ikonet er ét kald, og en mail mere i indbakken
|
||||
// er præcis det, dette program findes for at slippe for.
|
||||
|
||||
const planScope = "dailyplan"
|
||||
|
||||
// planSettings er, hvad én person har bedt om.
|
||||
//
|
||||
// Slået fra som udgangspunkt. En besked, man ikke har bedt om, klokken syv om
|
||||
// morgenen, er ikke en hjælp — og modsat beaconet er der ingen, der har brug for,
|
||||
// at det her er tændt for at kunne gøre sit arbejde.
|
||||
type planSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Hour er lokal tid hos personen selv. Et tal og ikke et klokkeslæt: planen
|
||||
// sendes på timen, og et minuttal ville love en præcision, en time-tikkende
|
||||
// baggrundsjob ikke har.
|
||||
Hour int `json:"hour"`
|
||||
// LastSent er datoen, den sidst blev sendt, som "2006-01-02" i personens egen
|
||||
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
||||
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
||||
LastSent string `json:"last_sent,omitempty"`
|
||||
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
|
||||
//
|
||||
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
|
||||
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
|
||||
// andet end den besked, man fik i morges, er værre end begge dele hver for
|
||||
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
|
||||
// gæt hver gang man kigger.
|
||||
//
|
||||
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
|
||||
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
|
||||
Plan string `json:"plan,omitempty"`
|
||||
PlanDate string `json:"plan_date,omitempty"`
|
||||
PlanAt int64 `json:"plan_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
|
||||
out := planSettings{Hour: 7}
|
||||
stored, err := s.db.UserSettings(ctx, userID, planScope)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v, ok := stored["enabled"].(bool); ok {
|
||||
out.Enabled = v
|
||||
}
|
||||
if v, ok := stored["hour"].(float64); ok && v >= 0 && v <= 23 {
|
||||
out.Hour = int(v)
|
||||
}
|
||||
if v, ok := stored["last_sent"].(string); ok {
|
||||
out.LastSent = v
|
||||
}
|
||||
if v, ok := stored["plan"].(string); ok {
|
||||
out.Plan = v
|
||||
}
|
||||
if v, ok := stored["plan_date"].(string); ok {
|
||||
out.PlanDate = v
|
||||
}
|
||||
if v, ok := stored["plan_at"].(float64); ok {
|
||||
out.PlanAt = int64(v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
||||
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
||||
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
||||
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetPlanSettings er både indstillingen og dagens plan.
|
||||
//
|
||||
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
|
||||
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
|
||||
// to steder at huske at rydde en plan fra i går.
|
||||
//
|
||||
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
|
||||
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
|
||||
// et tomt kort.
|
||||
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.planSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "plan settings", err)
|
||||
return
|
||||
}
|
||||
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
|
||||
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
type planRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Hour *int `json:"hour"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req planRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.planSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "plan settings", err)
|
||||
return
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
p.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Hour != nil {
|
||||
if *req.Hour < 0 || *req.Hour > 23 {
|
||||
writeFieldErrors(w, map[string]string{"hour": "must be between 0 and 23"})
|
||||
return
|
||||
}
|
||||
p.Hour = *req.Hour
|
||||
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu.
|
||||
// Uden det her ville en, der flytter planen fra 07 til 16, først få den i
|
||||
// morgen — hvilket ligner, at indstillingen ikke virkede.
|
||||
p.LastSent = ""
|
||||
}
|
||||
if err := s.savePlanSettings(r.Context(), user.ID, p); err != nil {
|
||||
s.internal(w, r, "save plan settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
type planNowRequest struct {
|
||||
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
|
||||
//
|
||||
// En besked om noget, man står og ser på, er en besked, der lærer folk at
|
||||
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
|
||||
// hele dens formål: at vise, at push virker på det her apparat.
|
||||
Silent bool `json:"silent"`
|
||||
}
|
||||
|
||||
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
|
||||
//
|
||||
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
|
||||
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
|
||||
// lave en plan for den, der har beskeden slået fra.
|
||||
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
||||
var req planNowRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
plan, err := s.buildPlan(r.Context(), user)
|
||||
if err != nil {
|
||||
s.internal(w, r, "build plan", err)
|
||||
return
|
||||
}
|
||||
if plan == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
||||
return
|
||||
}
|
||||
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
|
||||
s.internal(w, r, "save plan", err)
|
||||
return
|
||||
}
|
||||
|
||||
sent := false
|
||||
if !req.Silent {
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: user.ID, Kind: "daily.plan",
|
||||
Title: planTitle(user.Locale), Body: plan,
|
||||
})
|
||||
sent = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"plan": plan, "sent": sent,
|
||||
"plan_at": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
|
||||
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
|
||||
p, err := s.planSettings(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
p.Plan = plan
|
||||
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
p.PlanAt = now.Unix()
|
||||
return s.savePlanSettings(ctx, user.ID, p)
|
||||
}
|
||||
|
||||
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
|
||||
// endnu i dag.
|
||||
//
|
||||
// Kaldt hver time frem for lagt på et klokkeslæt — samme form som backup og
|
||||
// beacon, og af samme grund: en instans, der sov klokken syv, ville ellers
|
||||
// springe dagen over. Og fordi klokken syv er syv forskellige steder: personens
|
||||
// egen tidszone afgør, hvornår det er morgen hos dem.
|
||||
func (s *Server) DailyPlans(ctx context.Context) error {
|
||||
users, err := s.db.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range users {
|
||||
user, err := s.db.UserByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, err := s.planSettings(ctx, user.ID)
|
||||
if err != nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().In(userLocation(user.Timezone))
|
||||
today := now.Format("2006-01-02")
|
||||
if p.LastSent == today || now.Hour() < p.Hour {
|
||||
continue
|
||||
}
|
||||
// Kun samme dag. En instans, der har været slukket i tre dage, skal ikke
|
||||
// sende tre planer, når den kommer op — de to af dem er om dage, der er
|
||||
// gået, og en plan for i forgårs er ikke en plan.
|
||||
if now.Hour() > p.Hour+3 {
|
||||
p.LastSent = today
|
||||
_ = s.savePlanSettings(ctx, user.ID, p)
|
||||
continue
|
||||
}
|
||||
|
||||
plan, err := s.buildPlan(ctx, user)
|
||||
if err != nil {
|
||||
s.log.Warn("build daily plan", "err", err, "user", user.ID)
|
||||
continue
|
||||
}
|
||||
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god
|
||||
// morgen, og en besked, der siger "der er ingenting", lærer folk at lade
|
||||
// være med at åbne beskeder.
|
||||
if plan != "" {
|
||||
s.notifyDirect(ctx, &store.Notification{
|
||||
UserID: user.ID, Kind: "daily.plan",
|
||||
Title: planTitle(user.Locale), Body: plan,
|
||||
})
|
||||
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
|
||||
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
|
||||
// ene overskrive den andens `last_sent`.
|
||||
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
|
||||
}
|
||||
p.LastSent = today
|
||||
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
||||
s.log.Warn("save plan settings", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPlan skriver dagens plan.
|
||||
//
|
||||
// Tallene kommer fra basen og sætningen fra modellen — i den rækkefølge. En model,
|
||||
// der selv skulle tælle, ville af og til tælle forkert, og en plan, der siger fire
|
||||
// opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model sat op,
|
||||
// står tallene alene, og det er stadig det, man havde brug for at vide.
|
||||
func (s *Server) buildPlan(ctx context.Context, user *store.User) (string, error) {
|
||||
loc := userLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
today := now.Format("2006-01-02")
|
||||
// I går, ikke i dag: grænserne i filteret er inklusive, så "før i dag" skrevet
|
||||
// som DueBefore: today ville tælle dagens egne opgaver med som sprunget over —
|
||||
// og en plan, der klokken syv om morgenen siger, at dagens opgaver allerede er
|
||||
// overskredet, er en plan, man holder op med at læse.
|
||||
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
|
||||
overdue, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{DueBefore: yesterday, Limit: 50})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
due, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
DueFrom: today, DueBefore: today, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(overdue) == 0 && len(due) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
plain := plainPlan(len(due), len(overdue), user.Locale)
|
||||
|
||||
cfg, err := s.aiConfigFor(ctx, user)
|
||||
if err != nil || !cfg.Configured() {
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(due)+len(overdue))
|
||||
for _, t := range overdue {
|
||||
lines = append(lines, "- "+t.Content+" ("+overdueWord(user.Locale)+" "+t.DueDate+")")
|
||||
}
|
||||
for _, t := range due {
|
||||
lines = append(lines, "- "+t.Content)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
written, err := ai.New(cfg).DailyPlan(ctx, lines, len(due), len(overdue), user.Locale)
|
||||
if err != nil {
|
||||
// Modellen er ikke det, planen står og falder med. Tallene er rigtige uden
|
||||
// den, og en morgen uden besked, fordi en API-nøgle er udløbet, er en
|
||||
// dårligere handel end en besked uden en velskrevet sætning.
|
||||
s.log.Info("daily plan without the model", "err", err, "user", user.ID)
|
||||
return plain, nil
|
||||
}
|
||||
return strings.TrimSpace(written), nil
|
||||
}
|
||||
|
||||
// aiConfigFor er aiConfig uden en request bag sig — baggrundsjobbet har ingen.
|
||||
//
|
||||
// AllowPrivate er falsk her, og det er med vilje: retten til at nå det private
|
||||
// net blev givet til en administrator, der sad og skrev adressen ind, og et job,
|
||||
// der kører klokken syv om morgenen, er ikke nogen, der sidder og skriver. En
|
||||
// model på maskinen selv skal derfor bruges gennem fladen, ikke af planen.
|
||||
func (s *Server) aiConfigFor(ctx context.Context, user *store.User) (ai.Config, error) {
|
||||
values, err := s.db.UserSettings(ctx, user.ID, "ai")
|
||||
if err != nil {
|
||||
return ai.Config{}, err
|
||||
}
|
||||
str := func(key string) string {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
}
|
||||
return ai.Config{
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// notifyDirect er notify uden en request. Baggrundsjobbet har ingen bruger i
|
||||
// konteksten og ingen actor — planen er ikke noget, nogen har gjort ved en.
|
||||
func (s *Server) notifyDirect(ctx context.Context, n *store.Notification) {
|
||||
if err := s.db.CreateNotification(ctx, n); err != nil {
|
||||
s.log.Warn("create notification", "err", err)
|
||||
return
|
||||
}
|
||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||
ID: n.ID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
go s.pushToUser(n.UserID, n.Title, n.Body, "")
|
||||
}
|
||||
|
||||
func planTitle(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "Today"
|
||||
}
|
||||
return "I dag"
|
||||
}
|
||||
|
||||
func overdueWord(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "was due"
|
||||
}
|
||||
return "forfaldt"
|
||||
}
|
||||
|
||||
// plainPlan er planen uden en model: to tal, og ikke et ord mere.
|
||||
//
|
||||
// Det er ikke en nødløsning, det er bunden. Det, man har brug for at vide klokken
|
||||
// syv, er om der er noget, der er sprunget over — og det tal er rigtigt, uanset
|
||||
// om nogen har lagt en API-nøgle ind.
|
||||
func plainPlan(due, overdue int, locale string) string {
|
||||
english := strings.HasPrefix(strings.ToLower(locale), "en")
|
||||
var parts []string
|
||||
if due > 0 {
|
||||
if english {
|
||||
parts = append(parts, strconv.Itoa(due)+" due today")
|
||||
} else {
|
||||
parts = append(parts, strconv.Itoa(due)+" forfalder i dag")
|
||||
}
|
||||
}
|
||||
if overdue > 0 {
|
||||
if english {
|
||||
parts = append(parts, strconv.Itoa(overdue)+" overdue")
|
||||
} else {
|
||||
parts = append(parts, strconv.Itoa(overdue)+" er sprunget over")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
||||
// hvor der er tre, er værre end ingen plan — så tallene tælles her, og modellen
|
||||
// får dem udleveret som kendsgerninger.
|
||||
func TestTheDailyPlanCountsForItself(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "ring til anders i dag")
|
||||
ts.quickTask(t, "køb mælk i dag")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("send planen: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["sent"] != true {
|
||||
t.Fatalf("planen blev ikke sendt: %v", out)
|
||||
}
|
||||
// Uden en model står tallene alene, og det er stadig det, man havde brug for
|
||||
// at vide klokken syv.
|
||||
if plan, _ := out["plan"].(string); plan != "2 forfalder i dag" {
|
||||
t.Errorf("plan = %q", plan)
|
||||
}
|
||||
|
||||
// Og den lander i klokken som en besked.
|
||||
list, unread := notifications(t, ts)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
if n := firstOf(t, list); n["kind"] != "daily.plan" {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god morgen, og
|
||||
// en besked, der siger "der er ingenting", lærer folk at lade være med at åbne
|
||||
// beskeder.
|
||||
func TestAnEmptyDaySendsNoPlan(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("send planen: %d", resp.StatusCode)
|
||||
}
|
||||
if out["sent"] != false {
|
||||
t.Errorf("der blev sendt en plan om ingenting: %v", out)
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||
t.Errorf("beskeder = %d, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu. Uden det
|
||||
// ville en, der flytter planen fra 07 til 16, først få den i morgen — hvilket
|
||||
// ligner, at indstillingen ikke virkede.
|
||||
func TestMovingThePlanHourClearsTodaysMark(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "noget i dag")
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{
|
||||
"enabled": true, "hour": 7,
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("slå planen til")
|
||||
}
|
||||
if _, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil); out["sent"] != true {
|
||||
t.Fatalf("planen blev ikke sendt: %v", out)
|
||||
}
|
||||
|
||||
resp, out := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 16})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("flyt timen: %d", resp.StatusCode)
|
||||
}
|
||||
if out["hour"] != float64(16) {
|
||||
t.Errorf("time = %v", out["hour"])
|
||||
}
|
||||
if out["last_sent"] != nil && out["last_sent"] != "" {
|
||||
t.Errorf("mærket for i dag blev stående: %v", out["last_sent"])
|
||||
}
|
||||
}
|
||||
|
||||
// En time, der ikke findes på et ur, er en fejl i feltet og ikke en 500.
|
||||
func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 25}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
|
||||
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
|
||||
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "noget i dag")
|
||||
|
||||
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
|
||||
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
|
||||
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
|
||||
}
|
||||
|
||||
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("lav planen: %d", resp.StatusCode)
|
||||
}
|
||||
if made["sent"] != false {
|
||||
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
|
||||
}
|
||||
if list, _ := notifications(t, ts); len(list) != 0 {
|
||||
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||
if after["plan"] != made["plan"] {
|
||||
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
|
||||
}
|
||||
if after["plan_at"] == nil {
|
||||
t.Error("planen bærer ikke, hvornår den blev lavet")
|
||||
}
|
||||
}
|
||||
|
||||
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
|
||||
// overskriften "i dag", er værre end et tomt kort.
|
||||
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.quickTask(t, "noget i dag")
|
||||
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("lav planen")
|
||||
}
|
||||
|
||||
// Sat en dag tilbage, som om den var lavet i går.
|
||||
user := userID(t, ts, "[email protected]")
|
||||
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
|
||||
if plan, _ := out["plan"].(string); plan != "" {
|
||||
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// AI'ens forslag, og hvad der sker med dem.
|
||||
//
|
||||
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
|
||||
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
|
||||
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
|
||||
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
|
||||
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
|
||||
// skal have ret tit nok til at spare et par tastetryk.
|
||||
//
|
||||
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
|
||||
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
|
||||
// gennem den samme som alt andet.
|
||||
|
||||
// aiSuggestionJSON er ét forslag på vej til fladen.
|
||||
type aiSuggestionJSON struct {
|
||||
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
|
||||
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
|
||||
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
|
||||
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
|
||||
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return nil, false
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
|
||||
// lægger sin egen nøgle ind.
|
||||
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
|
||||
return nil, false
|
||||
}
|
||||
return ai.New(cfg), true
|
||||
}
|
||||
|
||||
// projectNames er de projekter, modellen må nævne.
|
||||
//
|
||||
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
|
||||
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
|
||||
// nogen sagde ja til forslaget.
|
||||
func (s *Server) projectNames(r *http.Request) []string {
|
||||
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
|
||||
if err != nil {
|
||||
s.log.Warn("projects for ai", "err", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
if p.IsInbox {
|
||||
continue
|
||||
}
|
||||
out = append(out, p.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
|
||||
// skrives færdig.
|
||||
//
|
||||
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
|
||||
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
|
||||
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
|
||||
// spørgsmål og et ja pr. linje.
|
||||
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
inbox, err := s.db.InboxID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{inbox}, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "list inbox", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||
return
|
||||
}
|
||||
|
||||
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
|
||||
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
|
||||
// må ikke kunne flytte alle de andres forslag én plads — derfor står
|
||||
// nummeret i teksten og bliver læst tilbage.
|
||||
lines := make([]string, 0, len(tasks))
|
||||
for i, t := range tasks {
|
||||
line := strconv.Itoa(i+1) + ". " + t.Content
|
||||
if t.Description != "" {
|
||||
line += " — " + firstWords(t.Description, 30)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai tidy inbox", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||
for i, sug := range suggestions {
|
||||
n, line := numberedLine(sug.Line)
|
||||
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
|
||||
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
|
||||
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
|
||||
if n == 0 {
|
||||
n = i + 1
|
||||
}
|
||||
if n < 1 || n > len(tasks) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||
}
|
||||
|
||||
type applyLineRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Line string `json:"line"`
|
||||
}
|
||||
|
||||
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
|
||||
// om.
|
||||
//
|
||||
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
|
||||
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
|
||||
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
|
||||
// der ikke blev rørt.
|
||||
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req applyLineRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if strings.TrimSpace(req.Line) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
loc := userLocation(user.Timezone)
|
||||
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||
return
|
||||
}
|
||||
|
||||
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
|
||||
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
|
||||
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
|
||||
// slette noget.
|
||||
update := store.TaskUpdate{Content: &parsed.Content}
|
||||
if parsed.Priority != 0 {
|
||||
update.Priority = &parsed.Priority
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
update.SetDue, update.DueDate = true, parsed.DueDate
|
||||
}
|
||||
if parsed.Recurrence != "" {
|
||||
update.RecurrenceRule = &parsed.Recurrence
|
||||
}
|
||||
if len(parsed.Labels) > 0 {
|
||||
update.Labels, update.SetLabels = parsed.Labels, true
|
||||
}
|
||||
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
|
||||
s.storeError(w, r, "update task", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
|
||||
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
|
||||
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
|
||||
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
|
||||
s.internal(w, r, "move task", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
|
||||
}
|
||||
|
||||
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
|
||||
//
|
||||
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
|
||||
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
|
||||
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
|
||||
// bare at være forkert.
|
||||
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
client, ok := s.aiClientFor(w, r)
|
||||
if !ok {
|
||||
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.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(n.Body) == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
|
||||
if err != nil {
|
||||
s.log.Warn("ai note actions", "err", err)
|
||||
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]aiSuggestionJSON, 0, len(suggestions))
|
||||
for _, sug := range suggestions {
|
||||
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
|
||||
}
|
||||
|
||||
// handleAICreateFromNote laver én accepteret opgave fra en note.
|
||||
//
|
||||
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
|
||||
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
|
||||
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
|
||||
// den kom fra — og noten kan findes på sin titel.
|
||||
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
|
||||
var req applyLineRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
loc := userLocation(user.Timezone)
|
||||
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
|
||||
if strings.TrimSpace(parsed.Content) == "" {
|
||||
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
|
||||
return
|
||||
}
|
||||
|
||||
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
|
||||
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
|
||||
// linjen vinder, fordi det er valgt frem for arvet.
|
||||
projectID := n.ProjectID
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, r, "create task from note", err)
|
||||
return
|
||||
}
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
|
||||
//
|
||||
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
|
||||
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
|
||||
// model, der springer en opgave over, ville så flytte alle de følgende forslag
|
||||
// én plads, og så bliver "sæt dato på den her" sat på en anden.
|
||||
func numberedLine(s string) (int, string) {
|
||||
s = strings.TrimSpace(s)
|
||||
dot := strings.IndexByte(s, '.')
|
||||
if dot <= 0 || dot > 3 {
|
||||
return 0, s
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s[:dot] {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, s
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, strings.TrimSpace(s[dot+1:])
|
||||
}
|
||||
|
||||
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
|
||||
// fylde den med en hel note.
|
||||
func firstWords(s string, n int) string {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) <= n {
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
return strings.Join(fields[:n], " ") + " …"
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
|
||||
//
|
||||
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
|
||||
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
|
||||
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
|
||||
func fakeModel(t *testing.T, reply string) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]string{"content": reply}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
|
||||
// på en adresse i det private net, og bootstrap-kontoen er en.
|
||||
func (ts *testServer) useModel(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "base_url": url, "model": "prøvemodel",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
|
||||
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
|
||||
// anden opgave end den, den blev skrevet om.
|
||||
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[
|
||||
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
|
||||
]`))
|
||||
|
||||
first := ts.quickTask(t, "noget som helst")
|
||||
second := ts.quickTask(t, "anders 12345678")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
list, _ := out["suggestions"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("forslag = %v, want ét", list)
|
||||
}
|
||||
sug := list[0].(map[string]any)
|
||||
if sug["task_id"] != second {
|
||||
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
|
||||
}
|
||||
if sug["line"] != "Ring til Anders i morgen p1" {
|
||||
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
|
||||
}
|
||||
if sug["why"] == "" {
|
||||
t.Error("forslaget siger ikke hvorfor")
|
||||
}
|
||||
_ = first
|
||||
}
|
||||
|
||||
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
|
||||
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
|
||||
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[]`))
|
||||
taskID := ts.quickTask(t, "anders")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||
"task_id": taskID, "line": "Ring til Anders i morgen p1",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["content"] != "Ring til Anders" {
|
||||
t.Errorf("indhold = %v", out["content"])
|
||||
}
|
||||
if out["priority"] != float64(1) {
|
||||
t.Errorf("prioritet = %v, want 1", out["priority"])
|
||||
}
|
||||
if out["due_date"] == nil || out["due_date"] == "" {
|
||||
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
|
||||
}
|
||||
}
|
||||
|
||||
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
|
||||
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
|
||||
// opgave hen, hvor ejeren ikke leder efter den.
|
||||
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[]`))
|
||||
taskID := ts.quickTask(t, "anders")
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||||
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("accepter: %d", resp.StatusCode)
|
||||
}
|
||||
if after["project_id"] != before["project_id"] {
|
||||
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
|
||||
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
|
||||
// stole på.
|
||||
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `[
|
||||
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
|
||||
]`))
|
||||
|
||||
body := "# Møde med Anders\njeg sender prislisten fredag"
|
||||
noteID := ts.createNote(t, body)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
list, _ := out["suggestions"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("forslag = %v, want ét", list)
|
||||
}
|
||||
line := list[0].(map[string]any)["line"].(string)
|
||||
|
||||
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
|
||||
map[string]any{"line": line})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["content"] != "Send prisliste til Anders" {
|
||||
t.Errorf("opgave = %v", task["content"])
|
||||
}
|
||||
// Opgaven bærer, hvor den kom fra, som et notelink.
|
||||
if task["description"] != "[[Møde med Anders]]" {
|
||||
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
|
||||
}
|
||||
|
||||
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
if note["body"] != body {
|
||||
t.Errorf("noten blev rettet:\n%v", note["body"])
|
||||
}
|
||||
}
|
||||
|
||||
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
|
||||
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
|
||||
// noget".
|
||||
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
|
||||
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
|
||||
func (ts *testServer) quickTask(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("quickadd gav intet id: %v", out)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Svaret på et spørgsmål bærer, hvad det står på — og kun det, det faktisk brugte.
|
||||
// En liste over alt, der blev kigget i, er ikke en henvisning.
|
||||
func TestAskingAnswersFromYourOwnNotes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `{"answer": "Du lovede Anders prislisten fredag.", "sources": [1]}`))
|
||||
|
||||
ts.createNote(t, "# Møde med Anders\njeg sender prislisten fredag")
|
||||
ts.createNote(t, "# Noget helt andet\nanders er ikke nævnt her")
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/ask", map[string]any{"question": "hvad lovede jeg Anders?"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spørg: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if out["answer"] != "Du lovede Anders prislisten fredag." {
|
||||
t.Errorf("svar = %v", out["answer"])
|
||||
}
|
||||
sources, _ := out["sources"].([]any)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("kilder = %v, want én", sources)
|
||||
}
|
||||
src := sources[0].(map[string]any)
|
||||
if src["kind"] != "note" || src["title"] != "Møde med Anders" {
|
||||
t.Errorf("kilde = %v", src)
|
||||
}
|
||||
}
|
||||
|
||||
// Et spørgsmål, der ikke rammer noget, får ikke et opfundet svar. Man spørger om
|
||||
// sine egne noter, fordi man ikke selv kan huske det, og kan derfor ikke se, at
|
||||
// svaret var gættet.
|
||||
func TestAskingAboutNothingAnswersNothing(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.useModel(t, fakeModel(t, `{"answer": "det burde du huske", "sources": []}`))
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/ai/ask",
|
||||
map[string]any{"question": "kvadratrodenafenbanan"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("spørg: %d", resp.StatusCode)
|
||||
}
|
||||
if out["answer"] != "" {
|
||||
t.Errorf("der blev svaret uden noget at svare ud fra: %v", out["answer"])
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
// The bucket is cleared only when the login is *finished*.
|
||||
//
|
||||
// It used to be cleared here, on the strength of the password alone — before
|
||||
// the second factor had been asked for at all. That handed somebody who
|
||||
// already had the password an unlimited supply of ten-guess batches: log in,
|
||||
// get a fresh bucket and a fresh pending session, spend ten guesses on the
|
||||
// code, log in again. At three valid codes in any moment and twenty requests a
|
||||
// second, that is even odds inside an afternoon — against the one control that
|
||||
// exists precisely for when the password is already gone.
|
||||
//
|
||||
// So an account with a second factor clears nothing yet. handleLoginTOTP does
|
||||
// it, once the code is right.
|
||||
if !user.TOTPEnabled {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
}
|
||||
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
@@ -191,13 +205,52 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
session := sessionFrom(r.Context())
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
// Counted per account as well as per address.
|
||||
//
|
||||
// The address bucket alone is defeated by anybody with more than one, which is
|
||||
// the ordinary case — a phone and a laptop is already two. A code is six
|
||||
// digits: the only thing that makes it safe is that guesses are scarce, and
|
||||
// scarcity has to be counted against the thing being attacked.
|
||||
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
|
||||
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
||||
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"too many verification codes; wait a moment and try again")
|
||||
return
|
||||
}
|
||||
|
||||
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
|
||||
if err == nil {
|
||||
// The code is right; now it must also be unused. A TOTP code stays valid for
|
||||
// its whole window, so one observed in flight — a phishing page, a proxy,
|
||||
// over the shoulder — could otherwise be replayed until it expires. Spending
|
||||
// the step here makes it single-use: a second presentation of the same code,
|
||||
// or an earlier one, finds the step already gone.
|
||||
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
|
||||
if cerr != nil {
|
||||
s.internal(w, r, "consume totp step", cerr)
|
||||
return
|
||||
}
|
||||
if !fresh {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code has already been used")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
// different form while locked out of their account is a poor time to
|
||||
// introduce a second concept.
|
||||
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
||||
if rerr != nil || !used {
|
||||
// The pending session is left alive on purpose.
|
||||
//
|
||||
// Ending it on the first wrong digit was considered and rejected: it
|
||||
// would make somebody who mistyped one number type their password again,
|
||||
// and it buys almost nothing. What makes a six-digit code safe is that
|
||||
// guesses are scarce, and the per-account bucket above is what makes
|
||||
// them scarce — ten an hour, however many times the password is
|
||||
// re-entered. The attack this closes was never "one session, many
|
||||
// guesses"; it was "a fresh bucket for every password check".
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code is not valid")
|
||||
return
|
||||
@@ -205,6 +258,11 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("recovery code used", "user", user.ID)
|
||||
}
|
||||
|
||||
// Finished. Now the buckets may be cleared — both of them, so somebody who
|
||||
// fumbled the code twice is not still waiting once they get it right.
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
|
||||
@@ -27,6 +27,9 @@ type testServer struct {
|
||||
*httptest.Server
|
||||
db *store.DB
|
||||
client *http.Client
|
||||
// api er den samme server, routeren hænger på. Et par prøver har brug for at
|
||||
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
|
||||
api *Server
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
@@ -62,7 +65,8 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
// Discard: a passing test should print nothing, and these handlers log freely.
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
srv := httptest.NewServer(New(cfg, db, log, nil))
|
||||
api := New(cfg, db, log, nil)
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
@@ -75,6 +79,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
// The cookie jar is what makes this a session test rather than a
|
||||
// sequence of unrelated requests.
|
||||
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +306,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
|
||||
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
||||
// not lock the account.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
|
||||
// at slå den fra, og den har altid spurgt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
@@ -363,12 +372,58 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A TOTP code works once. A code seen in flight and replayed inside its window
|
||||
// must be refused the second time, or the second factor is only as good as the
|
||||
// thirty seconds an attacker has to reuse what they captured.
|
||||
func TestTOTPCodeIsSingleUse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
secret, _ := body["secret"].(string)
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// One code, captured once and presented twice.
|
||||
code := codeFor(t, secret)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// A second login, same code, same window.
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
||||
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
@@ -897,3 +952,65 @@ func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Andenfaktoren kan ikke slås til uden adgangskoden.
|
||||
//
|
||||
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
|
||||
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
|
||||
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
|
||||
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
|
||||
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
|
||||
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "det forkerte kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("en tom adgangskode blev accepteret")
|
||||
}
|
||||
}
|
||||
|
||||
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
|
||||
//
|
||||
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
|
||||
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
|
||||
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
|
||||
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
|
||||
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
|
||||
// her kunne blive ved i det uendelige.
|
||||
refused := 0
|
||||
for i := 0; i < 14; i++ {
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
continue
|
||||
}
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused == 0 {
|
||||
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ import (
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// defaultPublishMin er, hvor mange installationer der skal være, før tallet
|
||||
// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
|
||||
// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
|
||||
const defaultPublishMin = 25
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
@@ -30,6 +35,33 @@ type beaconConfig struct {
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
// LastVersion er den version, der sidst nåede frem. Gemt, fordi en
|
||||
// installation, der opdaterer sig selv om natten, ellers står som gårsdagens
|
||||
// version i op til et døgn — og en tælling efter version, der er en dag bagud,
|
||||
// kan man ikke se en udrulning i.
|
||||
LastVersion string `json:"last_version,omitempty"`
|
||||
// LastError er grunden til, at det sidste forsøg ikke nåede frem.
|
||||
//
|
||||
// Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
|
||||
// collector, prøver igen i morgen og siger ingenting, og det opdages først ved
|
||||
// at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
|
||||
// der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
|
||||
// alt er sendt.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt int64 `json:"last_error_at,omitempty"`
|
||||
// NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
|
||||
// uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
|
||||
// det, der husker, at det er blevet sagt.
|
||||
NoticeAck bool `json:"notice_ack"`
|
||||
// PublishMin er gulvet under det offentliggjorte tal.
|
||||
//
|
||||
// Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
|
||||
// udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
|
||||
// er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
|
||||
// der ingenting frem for et lille tal, ingen kan tolke.
|
||||
PublishMin int `json:"publish_min"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
@@ -40,7 +72,9 @@ type beaconConfig struct {
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
cfg := beaconConfig{
|
||||
Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
@@ -65,6 +99,21 @@ func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["last_version"].(string); ok {
|
||||
cfg.LastVersion = v
|
||||
}
|
||||
if v, ok := stored["last_error"].(string); ok {
|
||||
cfg.LastError = v
|
||||
}
|
||||
if v, ok := stored["last_error_at"].(float64); ok {
|
||||
cfg.LastErrorAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["notice_ack"].(bool); ok {
|
||||
cfg.NoticeAck = v
|
||||
}
|
||||
if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
|
||||
cfg.PublishMin = int(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
@@ -83,20 +132,31 @@ func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
"last_version": cfg.LastVersion,
|
||||
"last_error": cfg.LastError,
|
||||
"last_error_at": cfg.LastErrorAt,
|
||||
"notice_ack": cfg.NoticeAck,
|
||||
"publish_min": cfg.PublishMin,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
PublishMin int `json:"publish_min"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
// Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
|
||||
// frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
|
||||
// det eneste spor, et tavst beacon ellers efterlader.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt string `json:"last_error_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,10 +169,15 @@ func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
PublishMin: cfg.PublishMin,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.LastError != "" {
|
||||
out.LastError = cfg.LastError
|
||||
out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
@@ -129,6 +194,7 @@ type beaconRequest struct {
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
PublishMin *int `json:"publish_min"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -151,6 +217,13 @@ func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.PublishMin != nil {
|
||||
if *req.PublishMin < 0 {
|
||||
writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
|
||||
return
|
||||
}
|
||||
cfg.PublishMin = *req.PublishMin
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
@@ -237,6 +310,16 @@ func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err == nil && n < cfg.PublishMin {
|
||||
// Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
|
||||
// hvor lidt der skal til at flytte det — og et, der falder, ville blive
|
||||
// læst som "færre bruger det nu", også når det i virkeligheden var en
|
||||
// udrulning, der gik galt, eller en adresse, der stille holdt op med at
|
||||
// blive ramt.
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
@@ -269,11 +352,15 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
// Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
|
||||
// opdaterer sig selv om natten og først melder ind igen et døgn senere, står
|
||||
// som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
|
||||
// tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
|
||||
// et døgn efter at være opdateret.
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -289,9 +376,100 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
// Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
|
||||
// collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
|
||||
// "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
|
||||
// Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
|
||||
// savne en.
|
||||
cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
|
||||
// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
|
||||
// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
|
||||
// siden om noget, der virker.
|
||||
func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
cfg.LastVersion = Version
|
||||
cfg.LastError, cfg.LastErrorAt = "", 0
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
// --- beskeden, der bliver sagt uopfordret ------------------------------------------
|
||||
|
||||
// handleBeaconNotice says whether the disclosure is still owed, and what it would
|
||||
// say.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
|
||||
// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
|
||||
// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
|
||||
// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
|
||||
// dem, der ved, at der er noget at slå fra.
|
||||
//
|
||||
// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
|
||||
// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
|
||||
// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
|
||||
// løfte, der er noget værd: en, læseren selv kan kontrollere.
|
||||
//
|
||||
// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
|
||||
// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
|
||||
// ikke kan svare på det.
|
||||
func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"pending": !cfg.NoticeAck,
|
||||
"enabled": cfg.Enabled,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"version": Version,
|
||||
"collector_url": cfg.Collector,
|
||||
})
|
||||
}
|
||||
|
||||
type beaconNoticeRequest struct {
|
||||
// Keep er en peger og ikke en bool, og det er hele pointen.
|
||||
//
|
||||
// En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
|
||||
// aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
|
||||
// løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
|
||||
// tilsvarende ende og er blevet bidt af den.
|
||||
Keep *bool `json:"keep"`
|
||||
}
|
||||
|
||||
// handleAckBeaconNotice records that the notice has been seen, and turns the
|
||||
// beacon off if that was the answer.
|
||||
//
|
||||
// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
|
||||
// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
|
||||
// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
|
||||
func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconNoticeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Keep == nil {
|
||||
writeFieldErrors(w, map[string]string{"keep": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
cfg.NoticeAck = true
|
||||
cfg.Enabled = *req.Keep
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
|
||||
// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
|
||||
// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
|
||||
func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent besked: %d", resp.StatusCode)
|
||||
}
|
||||
if notice["pending"] != true {
|
||||
t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
|
||||
}
|
||||
if notice["enabled"] != true {
|
||||
t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
|
||||
}
|
||||
// De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
|
||||
settings := beaconSettingsOf(t, ts)
|
||||
if notice["instance_id"] != settings["instance_id"] {
|
||||
t.Errorf("id i beskeden = %v, i indstillingerne = %v",
|
||||
notice["instance_id"], settings["instance_id"])
|
||||
}
|
||||
if notice["collector_url"] != settings["collector_url"] {
|
||||
t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
|
||||
notice["collector_url"], settings["collector_url"])
|
||||
}
|
||||
if notice["version"] == "" || notice["version"] == nil {
|
||||
t.Error("beskeden siger ikke, hvilken version der bliver sendt")
|
||||
}
|
||||
}
|
||||
|
||||
// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
|
||||
// ved næste login.
|
||||
func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != true {
|
||||
t.Fatalf("behold: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et svar: %v", notice)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
|
||||
func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != false {
|
||||
t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
s := beaconSettingsOf(t, ts)
|
||||
if s["enabled"] != false {
|
||||
t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et nej: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
|
||||
// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
|
||||
func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
|
||||
t.Errorf("en tom krop talte som et svar: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
|
||||
// svares der ingenting frem for et lille tal, ingen kan tolke.
|
||||
func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
|
||||
"is_collector": true, "publish_count": true,
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("bliv collector")
|
||||
}
|
||||
|
||||
// Instansen tæller sig selv, så der er én. Gulvet er højere.
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tælling: %d", resp.StatusCode)
|
||||
}
|
||||
if out["installs"] != nil {
|
||||
t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
|
||||
}
|
||||
|
||||
// Sættes gulvet ned, står tallet der.
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
|
||||
map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("sænk gulvet")
|
||||
}
|
||||
if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
|
||||
t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if getErr == nil {
|
||||
// The task decides, not the URL.
|
||||
//
|
||||
// The check above is about the project the *client named*, and a client
|
||||
// names both halves of this address. Asking the project for permission and
|
||||
// then taking the task id from the same string ties nothing together: a
|
||||
// viewer on a shared project could name a project of their own — the Inbox,
|
||||
// which everybody owns and may edit — and put the shared task's id in the
|
||||
// filename. The project check passed on the Inbox and the write landed on a
|
||||
// task they may only read.
|
||||
//
|
||||
// GetTask does not close it either: it answers "may this person see it",
|
||||
// and a viewer may. `CanEdit` is the question, and it is the same one
|
||||
// handleUpdateTask asks — the API door has always asked it.
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// And it has to live where the address says it does. Without this an editor
|
||||
// on two projects can reach either through the other's URL — nothing is
|
||||
// escalated by that on its own, but a URL allowed to lie about where a
|
||||
// resource lives is how the case above got in.
|
||||
if existing.ProjectID != projectID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// An update. Only the fields a task client can express are touched;
|
||||
// everything else the task carries stays as it is.
|
||||
content := parsed.Summary
|
||||
|
||||
@@ -3,10 +3,17 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/gcal"
|
||||
"github.com/kristianwind/verdande/internal/ics"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -205,9 +212,21 @@ type calendarStatusJSON struct {
|
||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
||||
// an interface that quietly looks editable is one somebody will try to edit.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
|
||||
// Abonnementer står ved siden af Google-kontoen og ikke inde i den: man kan
|
||||
// have det ene uden det andet, og det er hele pointen med dem — en privat
|
||||
// konto, der ikke kan bruge en Internal OAuth-klient, kan abonnere.
|
||||
Subscriptions []store.CalendarAccount `json:"subscriptions"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
// Læst først, så de også kommer med, når der ingen Google-konto er.
|
||||
subscriptions, err := s.db.Subscriptions(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "subscriptions", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
|
||||
out := calendarStatusJSON{
|
||||
@@ -223,6 +242,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
out.Subscriptions = subscriptions
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
@@ -253,6 +273,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if calendars != nil {
|
||||
out.Calendars = calendars
|
||||
}
|
||||
out.Subscriptions = subscriptions
|
||||
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
|
||||
// why it takes a context and a user rather than a request.
|
||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
||||
// Abonnementer først, og deres fejl tæller ikke mod Google.
|
||||
//
|
||||
// De to har intet med hinanden at gøre ud over at ende samme sted: et
|
||||
// abonnement er en adresse, der hentes, hvor Google er et token, der udløber.
|
||||
// En kalender, hvis vært er nede, må ikke få det til at se ud som om
|
||||
// Google-forbindelsen er væk — og omvendt.
|
||||
written, err := s.syncSubscriptions(ctx, user)
|
||||
if err != nil {
|
||||
s.log.Warn("calendar subscriptions", "err", err, "user", user.ID)
|
||||
}
|
||||
|
||||
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return written, err
|
||||
}
|
||||
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
|
||||
@@ -414,7 +446,6 @@ func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, erro
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
for _, calendar := range calendars {
|
||||
if !calendar.Shown {
|
||||
continue
|
||||
@@ -546,3 +577,232 @@ func validDay(s string) bool {
|
||||
_, err := time.Parse(calendarDateLayout, s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// --- abonnementer -----------------------------------------------------------------
|
||||
|
||||
// maxICSBytes bounds one fetch. A published calendar of a decade of holidays is a
|
||||
// few hundred kilobytes; ten megabytes is not a calendar, and reading it into
|
||||
// memory on a schedule with nobody watching is how a homelab instance falls over
|
||||
// at three in the morning.
|
||||
const maxICSBytes = 10 << 20
|
||||
|
||||
// normaliseCalendarURL turns what somebody pastes into something that can be
|
||||
// fetched.
|
||||
//
|
||||
// `webcal://` is not a scheme anything speaks. It is a convention — the same URL
|
||||
// over https, with a scheme that makes a desktop hand it to the calendar app
|
||||
// instead of the browser — and every publisher writes their links that way. A
|
||||
// person pasting one has pasted the right address; refusing it would be refusing
|
||||
// them on a technicality.
|
||||
func normaliseCalendarURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if after, ok := strings.CutPrefix(raw, "webcal://"); ok {
|
||||
raw = "https://" + after
|
||||
}
|
||||
if !strings.HasPrefix(raw, "https://") && !strings.HasPrefix(raw, "http://") {
|
||||
return "", errors.New("must be an https:// or webcal:// address")
|
||||
}
|
||||
// The same wall the AI provider's base_url goes through, and for the same
|
||||
// reason: this is an address a user typed, and the server is about to fetch it
|
||||
// on a schedule of their choosing.
|
||||
if reason := safedial.CheckURL(raw); reason != "" {
|
||||
return "", errors.New(reason)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// icsClient is the client a subscribed calendar is fetched with.
|
||||
//
|
||||
// safedial, checked on the *resolved* address at connect time and on every
|
||||
// redirect hop — because a name that passed the check when it was pasted can
|
||||
// answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||
//
|
||||
// The field exists so a test can put an ordinary client here: httptest listens on
|
||||
// loopback, which is exactly what this refuses, so a test of the *format* cannot
|
||||
// otherwise reach its own server. It is never set outside a test, and the wall it
|
||||
// bypasses is tested where it belongs — `normaliseCalendarURL` refuses a private
|
||||
// address where it is written down, and `internal/push` proves the connect-time
|
||||
// half.
|
||||
func (s *Server) icsClient() *http.Client {
|
||||
if s.icsFetch != nil {
|
||||
return s.icsFetch
|
||||
}
|
||||
return safedial.Client(30 * time.Second)
|
||||
}
|
||||
|
||||
// fetchICS reads a subscribed calendar.
|
||||
//
|
||||
// safedial, not a plain client — checked on the resolved address at connect time
|
||||
// and on every redirect hop, because a name that passed the check when it was
|
||||
// pasted can answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||
func (s *Server) fetchICS(ctx context.Context, url string) (ics.Subscribed, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.5")
|
||||
req.Header.Set("User-Agent", "verdande/"+Version)
|
||||
|
||||
resp, err := s.icsClient().Do(req)
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return ics.Subscribed{}, fmt.Errorf("%s said %s", resp.Request.URL.Host, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1))
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
if len(body) > maxICSBytes {
|
||||
return ics.Subscribed{}, errors.New("that calendar is too large")
|
||||
}
|
||||
return ics.ParseCalendar(string(body)), nil
|
||||
}
|
||||
|
||||
// syncSubscriptions refreshes every calendar this person subscribes to by address.
|
||||
//
|
||||
// One failing address does not stop the others. A subscription is somebody else's
|
||||
// server, and the ordinary state of somebody else's server is that it is fine
|
||||
// until one morning it is not — a holiday feed that 404s must not take the
|
||||
// football club's fixtures down with it.
|
||||
func (s *Server) syncSubscriptions(ctx context.Context, user *store.User) (int, error) {
|
||||
subs, err := s.db.Subscriptions(ctx, user.ID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
var failures []string
|
||||
|
||||
for _, sub := range subs {
|
||||
if ctx.Err() != nil {
|
||||
return written, ctx.Err()
|
||||
}
|
||||
unlock := s.lockSync("calendar:" + sub.ID)
|
||||
|
||||
calendarID, err := s.db.SubscribedCalendarID(ctx, sub.ID)
|
||||
if err != nil {
|
||||
unlock()
|
||||
failures = append(failures, sub.URL)
|
||||
continue
|
||||
}
|
||||
|
||||
cal, err := s.fetchICS(ctx, sub.URL)
|
||||
if err != nil {
|
||||
unlock()
|
||||
s.log.Warn("subscribed calendar", "err", err, "url", sub.URL, "user", user.ID)
|
||||
failures = append(failures, sub.URL)
|
||||
continue
|
||||
}
|
||||
|
||||
// The name the file calls itself, once. A subscription is added under its
|
||||
// address because that is all there is to go on before it has been fetched.
|
||||
if err := s.db.RenameSubscribedCalendar(ctx, sub.ID, cal.Name); err != nil {
|
||||
s.log.Warn("name subscribed calendar", "err", err)
|
||||
}
|
||||
|
||||
rows := make([]store.CalendarEvent, 0, len(cal.Events))
|
||||
for _, e := range cal.Events {
|
||||
// Outside the window is not stored. The grid asks about a window, the
|
||||
// sweep replaces a window, and a decade of holidays in one file would
|
||||
// otherwise be a decade of rows rewritten every fifteen minutes.
|
||||
if e.EndDay < from || e.StartDay > to {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, store.CalendarEvent{
|
||||
RemoteID: e.UID, Summary: e.Summary, Location: e.Location, URL: e.URL,
|
||||
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||
StartDay: e.StartDay, EndDay: e.EndDay, AllDay: e.AllDay,
|
||||
})
|
||||
}
|
||||
if err := s.db.ReplaceCalendarEvents(ctx, calendarID, from, to, rows); err != nil {
|
||||
unlock()
|
||||
return written, err
|
||||
}
|
||||
if err := s.db.MarkCalendarSynced(ctx, sub.ID, time.Now()); err != nil {
|
||||
s.log.Warn("mark subscription synced", "err", err)
|
||||
}
|
||||
written += len(rows)
|
||||
unlock()
|
||||
}
|
||||
|
||||
if len(failures) == len(subs) {
|
||||
return written, fmt.Errorf("none of the %d subscribed calendars could be read", len(subs))
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// handleSubscribeCalendar adds a calendar somebody only has an address to.
|
||||
//
|
||||
// Fetched once, here, before it is stored. An address that cannot be read is a
|
||||
// typo far more often than it is a server that happens to be down, and telling
|
||||
// somebody now — while they are looking at the field they just pasted into — is
|
||||
// worth the two seconds. A subscription that silently never fills is the same
|
||||
// silence the notifications had.
|
||||
func (s *Server) handleSubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
url, err := normaliseCalendarURL(req.URL)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"url": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cal, err := s.fetchICS(ctx, url)
|
||||
if err != nil {
|
||||
s.log.Warn("subscribe calendar", "err", err, "user", user.ID)
|
||||
writeFieldErrors(w, map[string]string{"url": "that address could not be read"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(cal.Name)
|
||||
}
|
||||
if name == "" {
|
||||
name = url
|
||||
}
|
||||
|
||||
account, err := s.db.SubscribeCalendar(r.Context(), user.ID, url, name)
|
||||
if err != nil {
|
||||
// The unique index is the one refusal worth naming: subscribing twice to
|
||||
// the same address is the same calendar fetched twice, with no way to say
|
||||
// which one counts.
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeFieldErrors(w, map[string]string{"url": "that calendar is already subscribed"})
|
||||
return
|
||||
}
|
||||
s.storeError(w, r, "subscribe calendar", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The events it already fetched, rather than making the person wait fifteen
|
||||
// minutes for the sweep to prove it worked.
|
||||
if _, err := s.syncSubscriptions(r.Context(), user); err != nil {
|
||||
s.log.Warn("first subscription sync", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, account)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnsubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, chi.URLParam(r, "id")); err != nil {
|
||||
s.storeError(w, r, "unsubscribe calendar", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -225,17 +225,32 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
a := &store.Attachment{
|
||||
TaskID: taskID,
|
||||
CommentID: r.FormValue("comment_id"),
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
|
||||
// An attachment hangs on a task or on a comment, never both — the schema says
|
||||
// so, and the form may have supplied a comment id.
|
||||
if a.CommentID != "" {
|
||||
a.TaskID = ""
|
||||
//
|
||||
// The comment is resolved and checked against the task in the URL. It was
|
||||
// taken on trust, which meant the permission check above guarded the *task
|
||||
// somebody named* while the file landed on *whatever comment they asked for*.
|
||||
// A viewer on a shared project can list its comments, so she could read an id
|
||||
// there and then post to a task in her own project with that id attached: the
|
||||
// file appears under somebody else's comment, in a project she may only read,
|
||||
// and is served to everyone in it from this origin under a name she chose.
|
||||
// Somebody removed from a project could do the same with ids they kept.
|
||||
if id := r.FormValue("comment_id"); id != "" {
|
||||
owner, err := s.db.CommentTask(r.Context(), id)
|
||||
if err != nil || owner != taskID {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
a.CommentID, a.TaskID = id, ""
|
||||
}
|
||||
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||
//
|
||||
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
|
||||
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
|
||||
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
|
||||
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
|
||||
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
policy := resp.Header.Get("Content-Security-Policy")
|
||||
if policy == "" {
|
||||
t.Fatal("der er ingen Content-Security-Policy på svaret")
|
||||
}
|
||||
|
||||
// Målt på script-src alene, og ikke på hele politikken.
|
||||
//
|
||||
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
|
||||
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
|
||||
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
|
||||
// bliver til et kørende script, og det er derfor den, prøven ser på.
|
||||
scriptSrc := ""
|
||||
for _, part := range strings.Split(policy, ";") {
|
||||
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
|
||||
scriptSrc = part
|
||||
}
|
||||
}
|
||||
if scriptSrc == "" {
|
||||
t.Fatal("der er ingen script-src i politikken")
|
||||
}
|
||||
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
|
||||
if strings.Contains(scriptSrc, forbidden) {
|
||||
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
|
||||
}
|
||||
}
|
||||
|
||||
// Og de fire, der skal være der.
|
||||
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
|
||||
if !strings.Contains(policy, want) {
|
||||
t.Errorf("politikken mangler %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HSTS følger med, når instansen kører over HTTPS — og kun der.
|
||||
//
|
||||
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
|
||||
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
|
||||
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
|
||||
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
|
||||
func TestHSTSTracksHTTPS(t *testing.T) {
|
||||
overHTTP := newTestServer(t) // BaseURL er http://localhost
|
||||
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("HSTS sat over ren HTTP: %q", got)
|
||||
}
|
||||
|
||||
overHTTPS := newTestServerWith(t, func(c *config.Config) {
|
||||
c.BaseURL = "https://todo.example.dk"
|
||||
})
|
||||
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
|
||||
t.Error("ingen HSTS over HTTPS")
|
||||
} else if !strings.Contains(got, "includeSubDomains") {
|
||||
t.Errorf("HSTS uden includeSubDomains: %q", got)
|
||||
}
|
||||
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
|
||||
// mikrofon og placering, som appen aldrig beder om.
|
||||
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
|
||||
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
|
||||
}
|
||||
// COOP isolerer browserkonteksten og står på hvert svar.
|
||||
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
|
||||
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Et bilag må ikke kunne blive læst som et dokument.
|
||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||
if inlineImage[mime] {
|
||||
t.Errorf("%s står på listen over det, der vises inline", mime)
|
||||
}
|
||||
}
|
||||
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
|
||||
for _, mime := range []string{"image/png", "image/jpeg"} {
|
||||
if !inlineImage[mime] {
|
||||
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,10 +251,12 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
// takes a context and a user rather than a request.
|
||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||
//
|
||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
||||
// asking for less is what makes the consent screen something a person can agree
|
||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
||||
// record has to live on this side, and a record on this side has to be bounded.
|
||||
// The star is taken off an imported message now, so on a starred trigger the mail
|
||||
// leaves the query on its own and this list is a safety net rather than the only
|
||||
// record. It still earns its place: it covers the window between making the task
|
||||
// and the star coming off, and it is the whole of the dedup for a label trigger,
|
||||
// where the label is the person's and not ours to remove. A record on this side
|
||||
// has to be bounded.
|
||||
//
|
||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||
// which was not a bound but a way of forgetting.
|
||||
@@ -330,7 +332,16 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
projectID = inbox
|
||||
}
|
||||
|
||||
created := 0
|
||||
// Hentet først, skrevet bagefter. To gennemløb frem for ét, fordi modellen
|
||||
// skal se hele bunken på én gang: ét spørgsmål for femogtyve mails frem for
|
||||
// femogtyve spørgsmål er den samme oplysning til en femogtyvendedel af
|
||||
// ventetiden — og et gennemløb, der tager fem minutter, overlapper med det
|
||||
// næste.
|
||||
type fetched struct {
|
||||
id string
|
||||
msg gmail.Message
|
||||
}
|
||||
var pending []fetched
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
@@ -338,13 +349,27 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// A spent budget ends the run rather than turning into twenty-five failed
|
||||
// fetches logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
msg, err := client.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||
continue
|
||||
}
|
||||
pending = append(pending, fetched{id: id, msg: msg})
|
||||
}
|
||||
|
||||
forAI := make([]mailForAI, 0, len(pending))
|
||||
for _, f := range pending {
|
||||
forAI = append(forAI, mailForAI{
|
||||
From: gmail.SenderName(f.msg.From), Subject: f.msg.Subject, Snippet: f.msg.Snippet,
|
||||
})
|
||||
}
|
||||
written := s.mailTaskLines(ctx, user, forAI)
|
||||
|
||||
created := 0
|
||||
for i, f := range pending {
|
||||
id, msg := f.id, f.msg
|
||||
|
||||
subject := strings.TrimSpace(msg.Subject)
|
||||
if subject == "" {
|
||||
@@ -360,8 +385,22 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
Description: msg.Snippet + "\n\n" + msg.Link,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
// The thread, not the message: a reply arriving later is the same
|
||||
// conversation, and a second task for it would be the duplicate this
|
||||
// key exists to prevent.
|
||||
SourceKey: "gmail:thread:" + msg.ThreadID,
|
||||
}
|
||||
// Og modellens linje, hvis den skrev en til netop den her mail.
|
||||
s.applyMailLine(ctx, user, task, written[i], sender+": "+subject)
|
||||
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Somebody got there first — an earlier sweep, or another
|
||||
// assistant reading the same mailbox. Mark it seen and move on;
|
||||
// it is handled, which is all this loop wanted.
|
||||
seen[id] = true
|
||||
continue
|
||||
}
|
||||
s.log.Warn("gmail create task", "err", err)
|
||||
continue
|
||||
}
|
||||
@@ -370,6 +409,29 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// The star comes off every message this query returned that now has a task —
|
||||
// the ones made this run and the ones a run before it already made. Gated to the
|
||||
// triggers that are about the star: for a label trigger the star is the person's
|
||||
// own and not ours to touch, and dedup there rests on the seen list alone.
|
||||
//
|
||||
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
|
||||
// that would not come off. The likeliest reason it will not, right after this
|
||||
// ships, is a connection still holding the old read-only grant — the write is a
|
||||
// 403 until the account is reconnected, and this logs it rather than failing the
|
||||
// sync. It self-heals: the mail stays starred, stays in the next query, and is
|
||||
// tried again once the grant is there.
|
||||
if box.Trigger == "starred" || box.Trigger == "both" {
|
||||
unstar := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
unstar = append(unstar, id)
|
||||
}
|
||||
}
|
||||
if err := client.Unstar(ctx, unstar); err != nil {
|
||||
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
|
||||
}
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// The ids this run saw, on top of the ones already known.
|
||||
//
|
||||
@@ -431,8 +493,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// The one click that opens Google's consent screen already exists; what it needs
|
||||
// behind it is a registered client, and that is not something any app can conjure:
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
|
||||
// a restricted scope, so an id shipped inside a public image would be both
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||
// restricted scope, so an id shipped inside a public image would be both
|
||||
// extractable from it and unusable without Google's security review of every
|
||||
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
||||
// recreate the container to paste the result was not.
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// A Gmail failure has to survive being reported.
|
||||
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
// A message that becomes a task has its star taken off — the write that the whole
|
||||
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
|
||||
// the way the IMAP side unflags what it imports.
|
||||
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
|
||||
var unstarred struct {
|
||||
hit bool
|
||||
body struct {
|
||||
IDs []string `json:"ids"`
|
||||
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||
}
|
||||
}
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
|
||||
unstarred.hit = true
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &unstarred.body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
|
||||
writeTestJSON(w, map[string]any{
|
||||
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
|
||||
"payload": map[string]any{"headers": []map[string]string{
|
||||
{"name": "From", "value": "Anders <[email protected]>"},
|
||||
{"name": "Subject", "value": "Møde på fredag"},
|
||||
}},
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/messages"):
|
||||
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A live access token, so the run goes straight to the list without a refresh.
|
||||
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if created, _ := body["created"].(float64); created != 1 {
|
||||
t.Fatalf("created = %v, want 1", body["created"])
|
||||
}
|
||||
|
||||
if !unstarred.hit {
|
||||
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
|
||||
}
|
||||
if strings.Join(unstarred.body.IDs, ",") != "m1" {
|
||||
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
|
||||
}
|
||||
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
|
||||
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// doRaw sender en krop, der ikke er JSON — en genvej på en telefon sender ren
|
||||
// tekst, og en webhook-formular sender felter. Det er halvdelen af det, der
|
||||
// prøves her.
|
||||
func (ts *testServer) doRaw(t *testing.T, method, path, contentType, body string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
|
||||
var decoded map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
}
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
// hookURL henter personens egen krog-adresse, som fladen viser den.
|
||||
func (ts *testServer) hookURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/hook-url", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent krog-URL: %d", resp.StatusCode)
|
||||
}
|
||||
url, _ := out["url"].(string)
|
||||
if !strings.Contains(url, "/inbound/hook/") {
|
||||
t.Fatalf("url = %q", url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// hookPath er stien, testserveren kan kaldes på — URL'en er absolut, fordi den
|
||||
// skal kunne kopieres ind i en genvej på en telefon.
|
||||
func hookPath(url string) string {
|
||||
return url[strings.Index(url, "/inbound/hook/"):]
|
||||
}
|
||||
|
||||
// En linje ren tekst, skubbet ind udefra, bliver til en opgave i indbakken — og
|
||||
// betyder det samme, som den ville betyde i feltet øverst i fladen.
|
||||
func TestAPushFromAnotherAppLandsInTheInbox(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
resp, out := anon.doRaw(t, "POST", path, "text/plain",
|
||||
"Ring til Anders p1\nhan ringede i går")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("skub: %d, %v", resp.StatusCode, out)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
tasks, _ := list["tasks"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["content"] == "Ring til Anders" {
|
||||
found = task
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("opgaven kom ikke i indbakken: %v", tasks)
|
||||
}
|
||||
// Parseren er den samme som fladens: p1 er en prioritet og ikke en del af
|
||||
// teksten, og resten af kroppen står under opgaven.
|
||||
if found["priority"] != float64(1) {
|
||||
t.Errorf("prioritet = %v, want 1", found["priority"])
|
||||
}
|
||||
if found["description"] != "han ringede i går" {
|
||||
t.Errorf("beskrivelse = %v", found["description"])
|
||||
}
|
||||
}
|
||||
|
||||
// JSON fra en tjeneste, med de feltnavne folk faktisk sender.
|
||||
func TestTheHookReadsJSON(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
for _, body := range []string{
|
||||
`{"text": "Køb mælk"}`,
|
||||
`{"title": "Køb mælk"}`,
|
||||
`{"content": "Køb mælk"}`,
|
||||
} {
|
||||
resp, out := anon.doRaw(t, "POST", path, "application/json", body)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("%s: %d, %v", body, resp.StatusCode, out)
|
||||
}
|
||||
if out["content"] != "Køb mælk" {
|
||||
t.Errorf("%s gav %v", body, out["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// En krop, der siger JSON og ikke er det, læses ikke som tekst bagefter: en
|
||||
// opgave, der hedder `{"txt": …}`, er en dårligere fejlmeddelelse end et 422.
|
||||
resp, _ := anon.doRaw(t, "POST", path, "application/json", `{"txt": "skrevet forkert"}`)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("forkert JSON gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Et token, der ikke findes, er det samme svar som et, der aldrig har eksisteret.
|
||||
// Alt andet er en maskine til at gætte tokens med.
|
||||
func TestAnUnknownHookTokenIsNotFound(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
resp, _ := anon.doRaw(t, "POST", "/inbound/hook/deteringentingogsaaslet", "text/plain", "hej")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("ukendt token gav %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Den gamle URL holder op med at virke, når man skifter den. Det er den eneste vej
|
||||
// tilbage: adressen står i en genvej på en telefon eller i et script.
|
||||
func TestRotatingTheHookRetiresTheOldURL(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
old := hookPath(ts.hookURL(t))
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/hook-url/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("skift: %d", resp.StatusCode)
|
||||
}
|
||||
fresh := hookPath(out["url"].(string))
|
||||
if fresh == old {
|
||||
t.Fatal("den nye URL er den gamle")
|
||||
}
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
if resp, _ := anon.doRaw(t, "POST", old, "text/plain", "gammel"); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("den gamle URL virker stadig: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := anon.doRaw(t, "POST", fresh, "text/plain", "ny"); resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("den nye URL virker ikke: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingen tekst er ikke en opgave. En tom opgave i indbakken er værre end en fejl,
|
||||
// fordi den ser ud som om integrationen virker.
|
||||
func TestAnEmptyPushIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
path := hookPath(ts.hookURL(t))
|
||||
anon := newSignedOut(t, ts)
|
||||
|
||||
resp, _ := anon.doRaw(t, "POST", path, "text/plain", " \n ")
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("tom tekst gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Et token kan én ting. Det ligger i et script på en anden maskine, og hvad der
|
||||
// ellers kan nås med det, er hvad der er sluppet ud sammen med det.
|
||||
func TestTheHookTokenIsNotAnAPIToken(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
url := ts.hookURL(t)
|
||||
token := url[strings.LastIndex(url, "/")+1:]
|
||||
|
||||
anon := newSignedOut(t, ts)
|
||||
req, err := http.NewRequest("GET", anon.URL+"/api/v1/tasks", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := anon.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("krog-tokenet gav adgang til API'et")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -154,6 +157,191 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
|
||||
}
|
||||
|
||||
// --- en krog ind i indbakken --------------------------------------------------------
|
||||
|
||||
type hookURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleGetHookURL returns the personal address another program can push a task
|
||||
// to, minting the token on first ask.
|
||||
//
|
||||
// Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
|
||||
// jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
|
||||
// en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
|
||||
// mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter
|
||||
// — fra en genvej på telefonen, et script, en anden tjeneste.
|
||||
func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "hook token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
||||
}
|
||||
|
||||
// handleRotateHookURL mints a new one and retires the old.
|
||||
//
|
||||
// Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en
|
||||
// telefon eller i et script på en anden maskine, og der er ikke andet at trække
|
||||
// tilbage end tokenet selv.
|
||||
func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, r, "generate hook token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
||||
s.internal(w, r, "set hook token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
||||
}
|
||||
|
||||
func (s *Server) hookURL(token string) string {
|
||||
return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token
|
||||
}
|
||||
|
||||
type hookRequest struct {
|
||||
// Text er linjen, der bliver til opgaven. `title` og `content` tages med som
|
||||
// de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en
|
||||
// integration, der fejler på et feltnavn, fejler tavst hos den, der satte den
|
||||
// op.
|
||||
Text string `json:"text"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
// Note er det, der står under opgaven. `body` og `description` ligeså.
|
||||
Note string `json:"note"`
|
||||
Body string `json:"body"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (h hookRequest) line() string {
|
||||
for _, v := range []string{h.Text, h.Title, h.Content} {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h hookRequest) note() string {
|
||||
for _, v := range []string{h.Note, h.Body, h.Description} {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleInboundHook turns a POST from somewhere else into a task in the inbox.
|
||||
//
|
||||
// Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående
|
||||
// post: den, der kalder, er et script eller en genvej på en telefon, og hverken
|
||||
// kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i
|
||||
// den ene persons indbakke — hvor et API-token kan alt.
|
||||
//
|
||||
// Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
|
||||
// tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej,
|
||||
// der bare sender det, man har markeret. Første linje bliver til opgaven, resten
|
||||
// til noten under den — det er sådan, en tekst, man har delt, ser ud.
|
||||
//
|
||||
// Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma"
|
||||
// betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at
|
||||
// have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder.
|
||||
func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token"))
|
||||
if err != nil {
|
||||
// Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der
|
||||
// kender forskel, er en maskine til at gætte tokens med.
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
|
||||
return
|
||||
}
|
||||
|
||||
line, note := s.readHookBody(r)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
writeFieldErrors(w, map[string]string{"text": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
content := parsed.Content
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = line
|
||||
}
|
||||
|
||||
projectID := ""
|
||||
if parsed.Project != "" {
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
task := &store.Task{
|
||||
ProjectID: projectID, Content: content, Priority: parsed.Priority,
|
||||
Description: strings.TrimSpace(note), DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, r, "create task from hook", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
s.log.Info("task created from hook", "user", user.ID)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content})
|
||||
}
|
||||
|
||||
// readHookBody er de tre måder, en linje tekst kommer ind ad.
|
||||
//
|
||||
// Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret
|
||||
// Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en
|
||||
// genvej på en telefon sender, når den ikke er blevet bedt om andet.
|
||||
//
|
||||
// En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den,
|
||||
// der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder
|
||||
// `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422.
|
||||
func (s *Server) readHookBody(r *http.Request) (line, note string) {
|
||||
const maxBody = 64 << 10
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
kind := r.Header.Get("Content-Type")
|
||||
|
||||
switch {
|
||||
case strings.Contains(kind, "application/json"):
|
||||
var req hookRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return req.line(), req.note()
|
||||
|
||||
case strings.Contains(kind, "application/x-www-form-urlencoded"):
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
req := hookRequest{
|
||||
Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"),
|
||||
Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"),
|
||||
}
|
||||
return req.line(), req.note()
|
||||
}
|
||||
|
||||
// Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst,
|
||||
// man har delt fra en anden app, ud — en overskrift og noget under.
|
||||
text := strings.TrimSpace(string(body))
|
||||
first, rest, _ := strings.Cut(text, "\n")
|
||||
return first, strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
|
||||
func mailToken(address string) string {
|
||||
if i := strings.LastIndex(address, "<"); i >= 0 {
|
||||
@@ -214,6 +402,35 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The address is checked where it is typed, as well as when it is dialled. A
|
||||
// scheme other than http or https is refused outright, for everybody —
|
||||
// file:// and gopher:// are not providers.
|
||||
//
|
||||
// The private-network wall has one exception, and it is the point of the
|
||||
// provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model
|
||||
// on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150.
|
||||
// safedial refuses exactly those, so the address the field suggested in its
|
||||
// own placeholder was an address the server would not accept.
|
||||
//
|
||||
// Admins only, because the wall is there for a real reason: a server that
|
||||
// fetches a user-supplied address lets the person supplying it reach what
|
||||
// they cannot reach themselves. An admin on a self-hosted instance already
|
||||
// owns the host and everything on its network, so this grants them nothing
|
||||
// new. An invited user is a different person, and for them it stays shut.
|
||||
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin {
|
||||
if reason := safedial.CheckURL(url); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
req.BaseURL = url
|
||||
}
|
||||
|
||||
existing, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "ai settings", err)
|
||||
@@ -246,11 +463,20 @@ func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
}
|
||||
// Worked out per request rather than stored beside the address: it answers
|
||||
// "may this person reach the private network", and somebody who is no longer
|
||||
// an admin should stop being able to. A flag written at save time would
|
||||
// outlive the role that granted it.
|
||||
allowPrivate := false
|
||||
if u := userFrom(r.Context()); u != nil && u.IsAdmin {
|
||||
allowPrivate = true
|
||||
}
|
||||
return ai.Config{
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
Provider: ai.Provider(str("provider")),
|
||||
APIKey: str("api_key"),
|
||||
BaseURL: str("base_url"),
|
||||
Model: str("model"),
|
||||
AllowPrivate: allowPrivate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,18 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// --- MCP -----------------------------------------------------------------------------
|
||||
@@ -250,6 +255,116 @@ func (ts *testServer) apiToken(t *testing.T, userID string) string {
|
||||
return token
|
||||
}
|
||||
|
||||
// The promise of sharing has to hold at every door, not just the API's.
|
||||
//
|
||||
// TestViewerCannotMutateThroughTheAPI asserts a viewer cannot write, and it
|
||||
// asserts it against /api/v1. CalDAV is a second door into the same tasks, and it
|
||||
// authorised differently: it checked the caller's standing in the project *named
|
||||
// in the URL* and then took the task id from the URL as well. Nothing tied the
|
||||
// two together.
|
||||
//
|
||||
// So a viewer on somebody else's project could name a project of their own —
|
||||
// their Inbox, which everybody owns and everybody may edit — and put the other
|
||||
// project's task id in the filename. The project check passed on the Inbox, and
|
||||
// the write landed on a task in a project they may only read.
|
||||
func TestAViewerCannotWriteThroughCalDAV(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "[email protected]", "Viewer")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "en opgave", "project_id": projectID,
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "viewer",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
them, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, them.ID)
|
||||
|
||||
// Their own Inbox: a project they own and may edit, and the one every account
|
||||
// has. It is what makes the URL check pass.
|
||||
projects, err := ts.db.ListProjects(t.Context(), them.ID, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var inbox string
|
||||
for _, p := range projects {
|
||||
if p.IsInbox {
|
||||
inbox = p.ID
|
||||
}
|
||||
}
|
||||
if inbox == "" {
|
||||
t.Fatal("the viewer has no inbox")
|
||||
}
|
||||
|
||||
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||
"\r\nSUMMARY:kapret\r\nSTATUS:COMPLETED\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
resp := ts.dav(t, "PUT", "/caldav/"+them.ID+"/"+inbox+"/"+taskID+".ics",
|
||||
token, them.Email, vtodo)
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("a viewer wrote through CalDAV: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And the task is as its owner left it. The status alone is not the assertion:
|
||||
// the question is whether anything moved.
|
||||
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if after["content"] != "en opgave" {
|
||||
t.Errorf("the task was renamed by a viewer: %v", after["content"])
|
||||
}
|
||||
if after["completed"] != false {
|
||||
t.Error("the task was completed by a viewer")
|
||||
}
|
||||
}
|
||||
|
||||
// A task may only be addressed under the project it is actually in.
|
||||
//
|
||||
// The same hole seen from the other side: an editor on two projects could reach a
|
||||
// task in one of them through the other's URL. Nothing is escalated by it — they
|
||||
// may edit both — but it is the check that was missing, and a URL that lies about
|
||||
// where a resource lives is how the viewer case got in.
|
||||
func TestCalDAVRefusesATaskFromAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, user.ID)
|
||||
|
||||
_, one := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Et"})
|
||||
_, two := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "To"})
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "hører til Et", "project_id": one["id"].(string),
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + taskID +
|
||||
"\r\nSUMMARY:flyttet i smug\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
resp := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+two["id"].(string)+"/"+taskID+".ics",
|
||||
token, user.Email, vtodo)
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("a task was written under the wrong project: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if after["content"] != "hører til Et" {
|
||||
t.Errorf("the task changed: %v", after["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalDAVRequiresCredentials(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -562,6 +677,128 @@ func TestAIKeyIsNeverReturned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting something else must not delete the AI provider.
|
||||
//
|
||||
// Reported from use as "the AI settings are not saved": they were saved, and then
|
||||
// taken. `user_settings` had `user_id` as its whole primary key, and the upsert
|
||||
// renamed `scope` on conflict — so the `ai`, `gmail` and `calendar` scopes shared
|
||||
// one row and each write deleted the other two. Every write returned 204.
|
||||
//
|
||||
// Through the handlers, because that is where it was seen. The shape is asserted
|
||||
// against the store as well: see TestOnePersonCanHaveSettingsForMoreThanOneThing.
|
||||
func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle",
|
||||
})
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the calendar and the mailbox handlers do when a handshake starts: write
|
||||
// their own scope for the same person.
|
||||
for _, scope := range []string{"calendar", "gmail"} {
|
||||
if err := ts.db.SetUserSettings(t.Context(), user.ID, scope,
|
||||
map[string]any{"pkce_state": "s-" + scope}); err != nil {
|
||||
t.Fatalf("save %s: %v", scope, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||
if body["provider"] != "anthropic" {
|
||||
t.Errorf("the provider is %v after connecting two other things, want anthropic", body["provider"])
|
||||
}
|
||||
if body["model"] != "claude-sonnet-5" {
|
||||
t.Errorf("the model is %v", body["model"])
|
||||
}
|
||||
if body["has_key"] != true {
|
||||
t.Error("the key was taken with it")
|
||||
}
|
||||
}
|
||||
|
||||
// Reported from use as "the AI settings are not saved" — the second time, and a
|
||||
// different cause than the one above.
|
||||
//
|
||||
// The provider is called "OpenAI-kompatibel (Ollama, LM Studio, …)" and the
|
||||
// address field suggests http://localhost:11434/v1. Both of those live on the
|
||||
// private network, which safedial refuses — so the address the interface proposed
|
||||
// was the address the server rejected, and the one provider that exists for
|
||||
// running your own model could not be configured at all.
|
||||
//
|
||||
// It looked like a save that did nothing, because the 422 came back on a field
|
||||
// with nowhere to show it. Two bugs wearing one coat.
|
||||
func TestAdminMayPointAIAtAModelOnTheLAN(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// The three shapes a self-hosted model actually takes: another machine on the
|
||||
// LAN, the loopback name, and the loopback address.
|
||||
for _, addr := range []string{
|
||||
"http://192.168.1.150:8080/v1",
|
||||
"http://localhost:11434/v1",
|
||||
"http://127.0.0.1:1234/v1",
|
||||
} {
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "gemma-4-26b-qat", "base_url": addr,
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("save %s: status %d, body %v", addr, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||
if body["base_url"] != "http://127.0.0.1:1234/v1" {
|
||||
t.Errorf("base_url = %v after saving, want it to have survived", body["base_url"])
|
||||
}
|
||||
if body["provider"] != "compatible" {
|
||||
t.Errorf("provider = %v", body["provider"])
|
||||
}
|
||||
}
|
||||
|
||||
// The wall stays up for everybody else.
|
||||
//
|
||||
// An admin on a self-hosted instance already owns the host and its network, so a
|
||||
// private destination grants them nothing they did not have. An invited user is a
|
||||
// different person, and for them the address field would be a way to find out
|
||||
// what answers on the host's network.
|
||||
func TestAnInvitedUserCannotPointAIAtThePrivateNetwork(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Andreas")
|
||||
|
||||
resp, body := other.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "llama3.1",
|
||||
"base_url": "http://192.168.1.150:8080/v1",
|
||||
})
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatal("an invited user was allowed to point the AI at the private network")
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["base_url"] == nil {
|
||||
t.Errorf("the refusal did not name the field it was about: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A scheme that is not http or https is refused for everybody, admin or not.
|
||||
func TestAISettingsRefuseANonHTTPAddress(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||
"provider": "compatible", "model": "llama3.1", "base_url": "file:///etc/passwd",
|
||||
})
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
t.Fatal("file:// was accepted as a provider address")
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["base_url"] == nil {
|
||||
t.Errorf("no error on base_url: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSettings(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -1316,3 +1553,172 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||
t.Error("the second rotation returned the same address")
|
||||
}
|
||||
}
|
||||
|
||||
// "Der kommer ingen notifikationer" må kunne besvares.
|
||||
//
|
||||
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
|
||||
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
|
||||
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
|
||||
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
|
||||
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
|
||||
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
|
||||
"title": "Prøve", "body": "kom den frem?",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["subscriptions"] != float64(0) {
|
||||
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
|
||||
}
|
||||
|
||||
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
|
||||
// som et navngivent afslag og ikke som tavshed.
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
|
||||
UserID: user.ID,
|
||||
Endpoint: "https://push.example.invalid/abc",
|
||||
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
|
||||
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
|
||||
if body["subscriptions"] != float64(1) {
|
||||
t.Fatalf("subscriptions = %v", body["subscriptions"])
|
||||
}
|
||||
failed, _ := body["failed"].([]any)
|
||||
if len(failed) != 1 {
|
||||
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
|
||||
}
|
||||
first, _ := failed[0].(map[string]any)
|
||||
if first["service"] != "push.example.invalid" {
|
||||
t.Errorf("service = %v", first["service"])
|
||||
}
|
||||
if first["reason"] == "" || first["reason"] == nil {
|
||||
t.Error("afslaget kom uden en grund")
|
||||
}
|
||||
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
|
||||
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
|
||||
if strings.Contains(fmt.Sprint(first), "/abc") {
|
||||
t.Errorf("endepunktet lækkede med ud: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// Et abonnement på en kalender, man kun har en adresse til.
|
||||
//
|
||||
// Bedt om som "abonnering af flere kalendere (ics og webcal?)". Google-vejen er
|
||||
// OAuth og én konto pr. person; det her er en adresse, og man har flere. Det er
|
||||
// også den eneste vej for en privat konto, som en Internal-registrering afviser
|
||||
// med `org_internal`.
|
||||
//
|
||||
// Prøven går uden om handleren og ind i det, den kalder. Grunden er væggen:
|
||||
// `httptest` lytter på loopback, og safedial nægter — både når adressen skrives
|
||||
// ned og igen når den hentes. Det er rigtigt, og det skal ikke kunne slås fra fra
|
||||
// en prøve. Så adressereglerne prøves for sig i TestWebcalIsRewrittenToHTTPS, og
|
||||
// her prøves resten af vejen: hentning, format, navn, gitteret og afmeldingen.
|
||||
func TestSubscribingToACalendarByAddress(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
day := time.Now().AddDate(0, 0, 2).Format("20060102")
|
||||
feedBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Helligdage\r\n" +
|
||||
"BEGIN:VEVENT\r\nUID:[email protected]\r\n" +
|
||||
"DTSTART;VALUE=DATE:" + day + "\r\nDTEND;VALUE=DATE:" + day + "\r\n" +
|
||||
"SUMMARY:Grundlovsdag\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
var hits int
|
||||
feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "text/calendar")
|
||||
w.Write([]byte(feedBody))
|
||||
}))
|
||||
defer feed.Close()
|
||||
ts.api.icsFetch = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Tilføjet under sin adresse, som er alt der er at gå efter, før den er hentet.
|
||||
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, feed.URL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ts.api.syncSubscriptions(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits == 0 {
|
||||
t.Fatal("adressen blev aldrig hentet")
|
||||
}
|
||||
|
||||
// Navnet kommer fra filen. X-WR-CALNAME er det, alle udgivere bruger; der
|
||||
// findes ingen standardegenskab for det.
|
||||
_, status := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
subs, _ := status["subscriptions"].([]any)
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("%d abonnementer i svaret", len(subs))
|
||||
}
|
||||
first, _ := subs[0].(map[string]any)
|
||||
if first["account"] != "Helligdage" {
|
||||
t.Errorf("navnet er %v, want Helligdage", first["account"])
|
||||
}
|
||||
|
||||
// Og begivenheden står i gitteret.
|
||||
from := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
to := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
||||
_, events := ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||
list, _ := events["events"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("%d begivenheder efter abonnementet", len(list))
|
||||
}
|
||||
if e, _ := list[0].(map[string]any); e["summary"] != "Grundlovsdag" {
|
||||
t.Errorf("begivenheden er %v", e["summary"])
|
||||
}
|
||||
|
||||
// Den samme adresse to gange er den samme kalender hentet to gange, og der er
|
||||
// ingen måde at sige hvilken der gælder.
|
||||
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, ""); !errors.Is(err, store.ErrDuplicate) {
|
||||
t.Errorf("den samme adresse blev accepteret to gange: %v", err)
|
||||
}
|
||||
|
||||
// Fjernet igen, og begivenhederne går med.
|
||||
resp, _ := ts.do(t, "DELETE", "/api/v1/calendar/subscriptions/"+first["id"].(string), nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unsubscribe: status %d", resp.StatusCode)
|
||||
}
|
||||
_, events = ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||
if list, _ := events["events"].([]any); len(list) != 0 {
|
||||
t.Errorf("%d begivenheder blev tilbage efter afmeldingen", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// webcal:// er https med et andet skema foran. Det er ikke en protokol, noget
|
||||
// taler — det er en konvention, der får et skrivebord til at give adressen til
|
||||
// kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres
|
||||
// links sådan. At afvise den ville være at afvise folk på en teknikalitet.
|
||||
func TestWebcalIsRewrittenToHTTPS(t *testing.T) {
|
||||
got, err := normaliseCalendarURL("webcal://example.dk/kalender.ics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "https://example.dk/kalender.ics" {
|
||||
t.Errorf("blev til %q", got)
|
||||
}
|
||||
|
||||
// Og en adresse indad afvises, hvor den skrives — ikke først når den hentes.
|
||||
if _, err := normaliseCalendarURL("http://127.0.0.1:8080/x.ics"); err == nil {
|
||||
t.Error("en loopback-adresse blev godtaget")
|
||||
}
|
||||
if _, err := normaliseCalendarURL("file:///etc/passwd"); err == nil {
|
||||
t.Error("et skema, der ikke er http, blev godtaget")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,13 +173,22 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Ét spørgsmål til modellen for hele gennemløbet, inden der bliver skrevet
|
||||
// noget. Er der ingen model, er kortet tomt, og hver opgave hedder afsender og
|
||||
// emne, som den altid har gjort.
|
||||
forAI := make([]mailForAI, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
forAI = append(forAI, mailForAI{From: msg.From, Subject: msg.Subject, Snippet: msg.Snippet})
|
||||
}
|
||||
written := s.mailTaskLines(ctx, user, forAI)
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
for i, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
// below has to be written for it: a run that created tasks and forgot how
|
||||
@@ -202,8 +211,25 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
Description: msg.Snippet,
|
||||
Priority: 4,
|
||||
CreatedBy: user.ID,
|
||||
// Host and uid together: a uid is only unique within one mailbox on
|
||||
// one server, and two accounts will hand out the same small numbers.
|
||||
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
|
||||
}
|
||||
// Og modellens linje, hvis den skrev en til netop den her. Emnet bliver
|
||||
// stående i beskrivelsen: har den misforstået mailen, skal det oprindelige
|
||||
// kunne læses ved siden af.
|
||||
s.applyMailLine(ctx, user, task, written[i], content)
|
||||
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
// Already a task. Still count it as read, or last_uid never moves
|
||||
// past it and every sweep from here on stops at the same message.
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -527,7 +527,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
||||
"is lost. Two things in the text are read out of it and become links: " +
|
||||
"`#Projekt` files the note against that project, so it appears on the " +
|
||||
"project's page without anybody filing it there, and `[[Another note]]` " +
|
||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||
"links to a note by its title. A third, `@Navn`, shares the note with that " +
|
||||
"person and tells them — so only write one when the note is meant for them. " +
|
||||
"Use this to bring notes in from elsewhere — " +
|
||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||
|
||||
@@ -19,21 +19,38 @@ import (
|
||||
|
||||
// Notes.
|
||||
//
|
||||
// Access follows the project, because that is where a note lives: a note in a
|
||||
// shared project is readable by everybody who can read the project and writable
|
||||
// by an editor. A note with no project is its author's alone — the same answer a
|
||||
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
|
||||
// Access has three ways in, and a note may have any of them at once. A note with
|
||||
// no project is its author's alone — the same answer a task in the inbox gets. A
|
||||
// note in a project is readable by everyone who can read the project and writable
|
||||
// by an editor. And a note can be shared with a person directly, which grants that
|
||||
// person a role of its own. The effective role is the most any of the three allow.
|
||||
|
||||
// mayReadNote reports whether this person may see the note, and mayWriteNote
|
||||
// whether they may change it. Split, because a viewer on a shared project can do
|
||||
// the first and not the second.
|
||||
// mayTouchNote reports whether this person may act on the note at the given role —
|
||||
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
||||
// on a shared project or a shared note, may do the first and not the second.
|
||||
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
||||
user := userFrom(r.Context())
|
||||
// The owner and the project path, exactly as before.
|
||||
if n.ProjectID == "" {
|
||||
return n.CreatedBy == user.ID
|
||||
if n.CreatedBy == user.ID {
|
||||
return true
|
||||
}
|
||||
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
|
||||
return err == nil
|
||||
// A direct share grants its role on top of whatever the above allow.
|
||||
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
|
||||
return shareRole.AtLeast(role)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownsNote reports whether this person is the note's owner — its created_by — which
|
||||
// is who, and only who, may hand it to other people. An editor on a shared note can
|
||||
// change its text; giving away access is the owner's alone, the same shape a
|
||||
// project takes where members edit but only the owner invites.
|
||||
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
|
||||
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +79,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
// The archive is the same list asked the other way round, so it is the same
|
||||
// endpoint with a flag rather than a second one that would have to be kept in
|
||||
// step with this.
|
||||
if r.URL.Query().Get("archived") != "" {
|
||||
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list archived notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,7 +104,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
||||
}
|
||||
|
||||
// briefly replaces each note's body with the first of it.
|
||||
//
|
||||
// The list shows a title and about ninety characters. It was being sent whole
|
||||
// notes to do that: with twelve hundred of them the response was 845 KB, of which
|
||||
// ninety-eight per cent was body text nobody would read on that screen — fetched,
|
||||
// parsed and held in memory on every visit.
|
||||
//
|
||||
// Body is emptied rather than shortened, and that is the important half. A field
|
||||
// called `body` that holds a *piece* of the body is a trap: the editor would open
|
||||
// it, the debounce would save it, and the note would be cut down to its own
|
||||
// preview by nothing more than being looked at. Empty is a shape the client can
|
||||
// check, and it does — see the notes page, which fetches the whole note when it
|
||||
// opens one.
|
||||
//
|
||||
// Four hundred characters rather than ninety: the client strips Markdown before
|
||||
// it shows anything, and a heading marker and a bold word are characters that
|
||||
// vanish. Cheap insurance against a preview that ends up shorter than the line it
|
||||
// is meant to fill.
|
||||
const previewChars = 400
|
||||
|
||||
func briefly(notes []store.Note) []store.Note {
|
||||
out := make([]store.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
body := []rune(n.Body)
|
||||
if len(body) > previewChars {
|
||||
body = body[:previewChars]
|
||||
}
|
||||
n.Preview = string(body)
|
||||
n.Body = ""
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readable drops what this person may not see.
|
||||
@@ -140,9 +204,152 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "create note", err)
|
||||
return
|
||||
}
|
||||
// En ny note kan være den, en delt note hele tiden har peget på: linket er
|
||||
// skrevet som en titel, og titlen fandtes ikke før nu.
|
||||
//
|
||||
// Kun når der står noget i den. En tom note har ingen titel, og et link peger på
|
||||
// en titel — så der er intet at regne, og det kostede alligevel: udregningen
|
||||
// åbner hver eneste af ejerens noter, og den lå her mellem "Ny note" og det
|
||||
// svar, fladen venter på for at kunne tegne editoren. Halvfems millisekunder er
|
||||
// nok til at nå at taste den første linje ind i den forrige note, som så blev
|
||||
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
||||
if strings.TrimSpace(n.Body) != "" {
|
||||
s.followLinks(r, n)
|
||||
s.shareWithMentioned(r, n, "")
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, n)
|
||||
}
|
||||
|
||||
// followLinks holder de udledte delinger i takt med teksten.
|
||||
//
|
||||
// De noter, en delt note peger på, følger med — og teksten kan ændre sig, efter
|
||||
// delingen er sat. Et link skrevet i dag i en note, der blev delt i går, skal føre
|
||||
// samme sted hen for begge.
|
||||
//
|
||||
// Kun ejerens egne gemninger regner om, og det er ikke en optimering. Et link
|
||||
// peger på en titel, og en medredaktør må skrive i teksten — så hvis enhver
|
||||
// gemning talte med, kunne den, man har delt en note med, skrive
|
||||
// `[[Bankoplysninger]]` ind i den og dermed dele den med sig selv.
|
||||
//
|
||||
// Det lukker ikke hullet helt, og det skal siges frem for at lyde lukket: skriver
|
||||
// medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note
|
||||
// med dér. Det, der står tilbage mod det, er synlighed — linket står som tekst i
|
||||
// ejerens egen note, og delingspanelet viser hver note, der er fulgt med, og hvem
|
||||
// der har den. En stille eskalering er det ikke; en, man kan overse, er den.
|
||||
//
|
||||
// Fejler det, siges det i loggen og ikke til den, der gemte: noten *er* gemt, og
|
||||
// et svar, der lyder som om den ikke er, ville få dem til at skrive den igen.
|
||||
func (s *Server) followLinks(r *http.Request, n *store.Note) {
|
||||
if n == nil || n.CreatedBy != userFrom(r.Context()).ID {
|
||||
return
|
||||
}
|
||||
if _, err := s.db.SyncLinkedShares(r.Context(), n.CreatedBy); err != nil {
|
||||
s.log.Error("sync linked shares", "err", err, "note", n.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @.
|
||||
//
|
||||
// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var
|
||||
// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den
|
||||
// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den.
|
||||
//
|
||||
// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde
|
||||
// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville
|
||||
// hver eneste gemning være en ny deling og en ny besked om det samme navn.
|
||||
//
|
||||
// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den
|
||||
// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele
|
||||
// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet
|
||||
// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver
|
||||
// kun besked til en, der kan se noten i forvejen.
|
||||
//
|
||||
// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der
|
||||
// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet,
|
||||
// og en rolle, der er hævet dér, sænkes ikke igen af en omtale.
|
||||
// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også
|
||||
// fortæller dem, at noten er blevet rettet.
|
||||
func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool {
|
||||
told := map[string]bool{}
|
||||
if n == nil || n.CreatedBy == "" {
|
||||
return told
|
||||
}
|
||||
// Slået op mod alle konti på instansen — det er dem, noten kan deles med, og
|
||||
// derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke
|
||||
// deles med den, der har den.
|
||||
people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy)
|
||||
if err != nil {
|
||||
s.log.Warn("people for mentions", "err", err, "note", n.ID)
|
||||
return told
|
||||
}
|
||||
|
||||
was := map[string]bool{}
|
||||
for _, id := range store.MentionsIn(before, people) {
|
||||
was[id] = true
|
||||
}
|
||||
fresh := []string{}
|
||||
for _, id := range store.MentionsIn(n.Body, people) {
|
||||
if !was[id] {
|
||||
fresh = append(fresh, id)
|
||||
}
|
||||
}
|
||||
if len(fresh) == 0 {
|
||||
return told
|
||||
}
|
||||
|
||||
actor := userFrom(r.Context())
|
||||
owner := actor.ID == n.CreatedBy
|
||||
shared := false
|
||||
for _, id := range fresh {
|
||||
if !owner {
|
||||
continue
|
||||
}
|
||||
// En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der
|
||||
// ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren
|
||||
// skrev deres navn i teksten.
|
||||
if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil {
|
||||
s.log.Warn("mention share role", "err", err, "note", n.ID)
|
||||
continue
|
||||
} else if has {
|
||||
continue
|
||||
}
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil {
|
||||
s.log.Warn("share with mentioned", "err", err, "note", n.ID)
|
||||
continue
|
||||
}
|
||||
shared = true
|
||||
}
|
||||
if shared {
|
||||
// Og det, noten peger på, følger med delingen — samme vej som en deling
|
||||
// sat i panelet.
|
||||
s.followLinks(r, n)
|
||||
}
|
||||
|
||||
// Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har
|
||||
// givet adgang, er med — og kun til dem, så en medredaktørs omtale af en
|
||||
// fremmed ikke bliver en besked om en note, de ikke kan åbne.
|
||||
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("note audience for mention", "err", err, "note", n.ID)
|
||||
return told
|
||||
}
|
||||
canSee := map[string]bool{}
|
||||
for _, id := range audience {
|
||||
canSee[id] = true
|
||||
}
|
||||
for _, id := range fresh {
|
||||
if !canSee[id] {
|
||||
continue
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||
Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title,
|
||||
})
|
||||
told[id] = true
|
||||
}
|
||||
return told
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var body noteBody
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
@@ -159,9 +366,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if body.ProjectID != nil {
|
||||
// Moving it means being allowed on both sides: you cannot file somebody
|
||||
// else's note into your project, nor yours into theirs.
|
||||
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
||||
// Filing a note is the owner's call, not an editor's. A shared editor may
|
||||
// change the text; letting them also move the note would let them file
|
||||
// somebody else's note into a project the owner is not in — and the owner,
|
||||
// who is not a member there, would lose their own note. So the move is
|
||||
// owner-only, and then only into a project the owner may write to.
|
||||
if !s.ownsNote(r, n) {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
|
||||
return
|
||||
}
|
||||
if *body.ProjectID != "" {
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
||||
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
||||
@@ -171,6 +385,7 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
n.ProjectID = *body.ProjectID
|
||||
}
|
||||
before := n.Body
|
||||
if body.Body != nil {
|
||||
n.Body = *body.Body
|
||||
}
|
||||
@@ -178,20 +393,67 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
n.Pinned = *body.Pinned
|
||||
}
|
||||
|
||||
// Læst før gemningen, sammenlignet efter. En gemning er en tastepause, ikke en
|
||||
// begivenhed, så de fleste af dem ændrer intet af det her — og udregningen
|
||||
// åbner hver eneste af ejerens noter, fordi titlerne er forseglet. Den skal
|
||||
// køre, når svaret kan være blevet et andet, og ellers ikke.
|
||||
wasLinking := store.LinksIn(before)
|
||||
wasTitled := n.Title
|
||||
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.internal(w, r, "update note", err)
|
||||
return
|
||||
}
|
||||
// Titlen tæller med: den *er* første linje, så en omskrevet overskrift flytter
|
||||
// det, andres links peger på, uden at et eneste link er rørt.
|
||||
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
||||
s.followLinks(r, n)
|
||||
}
|
||||
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
|
||||
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
|
||||
// det ville lære folk at overse dem alle sammen.
|
||||
if before != n.Body {
|
||||
// Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og
|
||||
// ikke også "rettede en note" om den samme gemning — det er én handling, og
|
||||
// den ene sætning siger mere end den anden.
|
||||
mentioned := s.shareWithMentioned(r, n, before)
|
||||
s.notifyNoteChanged(r, n, mentioned)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
}
|
||||
|
||||
func sameLinks(a, b []store.NoteLink) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
in := make(map[store.NoteLink]bool, len(a))
|
||||
for _, l := range a {
|
||||
in[l] = true
|
||||
}
|
||||
for _, l := range b {
|
||||
if !in[l] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
// Deleting is not editing. Someone the note was shared with — even as an editor
|
||||
// — may change its text, but throwing away the owner's note is the owner's to
|
||||
// do. A project editor keeps the reach they have always had over a project's
|
||||
// notes; a direct share does not extend that far.
|
||||
mayDelete := s.ownsNote(r, n)
|
||||
if !mayDelete && n != nil && n.ProjectID != "" {
|
||||
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
|
||||
mayDelete = perr == nil
|
||||
}
|
||||
if n == nil || !mayDelete {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
@@ -199,6 +461,9 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "delete note", err)
|
||||
return
|
||||
}
|
||||
// En note i papirkurven er ikke længere et udgangspunkt for noget. Det, den tog
|
||||
// med sig, skal derfor heller ikke blive stående hos dem, den blev delt med.
|
||||
s.followLinks(r, n)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -233,6 +498,18 @@ func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
||||
// the second, so two notes made in the same second have no order between them —
|
||||
// and then which of two same-titled notes gets the plain name and which gets
|
||||
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
||||
// thing by when it was made settles it: the first note of a name keeps the name.
|
||||
sort.SliceStable(notes, func(i, j int) bool {
|
||||
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
||||
return notes[i].ID < notes[j].ID
|
||||
}
|
||||
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
@@ -294,6 +571,14 @@ func noteFilename(title string) string {
|
||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||
const maxNoteImportBytes = 64 << 20
|
||||
|
||||
// What the archive may become once it is unpacked, and how many entries it may
|
||||
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
|
||||
// nothing at all about what it expands to.
|
||||
const (
|
||||
maxNoteImportUnpacked = 2 << 30
|
||||
maxNoteImportFiles = 20_000
|
||||
)
|
||||
|
||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
||||
//
|
||||
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||
@@ -332,24 +617,57 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// note is read its pictures already have addresses to be rewritten to — a note
|
||||
// and its images arrive in whatever order the archive happens to hold them, and
|
||||
// one pass would leave half the links pointing at nothing.
|
||||
// The archive as a whole, not just the upload.
|
||||
//
|
||||
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
|
||||
// MB can become sixty-six gigabytes on the way out — written into the same
|
||||
// volume the database lives on. The upload limit says nothing about that, and
|
||||
// nothing else did either.
|
||||
//
|
||||
// Two ceilings: one per file, and one across the archive. A single enormous
|
||||
// photograph and ten thousand small ones are different attacks with the same
|
||||
// ending.
|
||||
if len(zr.File) > maxNoteImportFiles {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that archive holds too many files")
|
||||
return
|
||||
}
|
||||
budget := int64(maxNoteImportUnpacked)
|
||||
|
||||
stored := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// The header's own claim first, which costs nothing to check and rejects
|
||||
// the obvious case before a byte is written. It is only a claim, so the
|
||||
// reader below is limited as well.
|
||||
if f.UncompressedSize64 > maxUploadBytes {
|
||||
s.log.Warn("import: a file in the archive is too large", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hashed, size, err := s.storeUpload(rc)
|
||||
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
s.log.Warn("import note file", "err", err, "file", name)
|
||||
continue
|
||||
}
|
||||
if size > maxUploadBytes || size > budget {
|
||||
// Over the line once it was actually read: remove what was written
|
||||
// rather than keeping a truncated file that would open as nothing.
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
|
||||
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
budget -= size
|
||||
stored[name] = hashed
|
||||
_ = size
|
||||
files++
|
||||
}
|
||||
|
||||
@@ -646,3 +964,74 @@ func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
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,77 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// En zip-bombe skal ikke kunne skrive gigabyte ned på den disk, databasen ligger
|
||||
// på.
|
||||
//
|
||||
// Uploaden er begrænset til 64 MB, og det siger ingenting om, hvad de 64 MB
|
||||
// bliver til: deflate når omkring 1032:1, så de kan pakkes ud til seksogtres
|
||||
// gigabyte. Prøven bygger en rigtig lille bombe — fire megabyte nuller, som
|
||||
// pakker til nogle få kilobyte — og ser efter, at loftet på det udpakkede holder.
|
||||
//
|
||||
// Skrevet imod tallene frem for imod endepunktet, fordi det, der skal holde, er
|
||||
// forholdet: en fil, hvis udpakkede størrelse overstiger loftet, må ikke læses
|
||||
// færdig.
|
||||
func TestAZipBombIsRefusedByItsUnpackedSize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("bombe.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nuller pakker næsten helt væk. Fire megabyte er nok til at vise forholdet
|
||||
// uden at prøven selv bruger en gigabyte.
|
||||
if _, err := w.Write(make([]byte, 4<<20)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
packed := int64(buf.Len())
|
||||
if packed > 64<<10 {
|
||||
t.Fatalf("prøvens egen bombe pakkede kun til %d bytes; den viser ikke noget", packed)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), packed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := zr.File[0]
|
||||
if f.UncompressedSize64 != 4<<20 {
|
||||
t.Fatalf("udpakket størrelse = %d", f.UncompressedSize64)
|
||||
}
|
||||
|
||||
// Det, importen gør: læs højst det, budgettet tillader, plus én byte — så en
|
||||
// fil, der er præcis på grænsen, kan skelnes fra en, der er over.
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rc.Close()
|
||||
budget := int64(1 << 20)
|
||||
n, err := io.Copy(io.Discard, io.LimitReader(rc, budget+1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n <= budget {
|
||||
t.Fatalf("læste %d bytes af en fil på 4 MB med et budget på %d — loftet fanger den ikke", n, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// Tallene skal stå i et forhold, der giver mening: en enkelt fil må aldrig kunne
|
||||
// bruge hele arkivets budget, og arkivet må ikke være mindre end én fil.
|
||||
func TestTheImportCeilingsAgreeWithEachOther(t *testing.T) {
|
||||
if maxNoteImportUnpacked <= maxUploadBytes {
|
||||
t.Error("arkivets budget er ikke større end én enkelt fil")
|
||||
}
|
||||
if maxNoteImportFiles < 2000 {
|
||||
t.Error("filloftet er under det, en rigtig Apple Noter-eksport indeholder")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og
|
||||
// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes.
|
||||
func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9")
|
||||
|
||||
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "[email protected]", "role": "editor"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
invited, _ := body["invited"].(map[string]any)
|
||||
if invited == nil || invited["email"] != "[email protected]" {
|
||||
t.Fatalf("the response does not say who was invited: %v", body)
|
||||
}
|
||||
if invited["role"] != "editor" {
|
||||
t.Errorf("role = %v, want the one the owner chose", invited["role"])
|
||||
}
|
||||
// Uden en postserver er linket det eneste, der findes, og det skal stå i
|
||||
// svaret frem for at blive lovet i en mail, der ikke kom af sted.
|
||||
if body["emailed"] != false {
|
||||
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
|
||||
}
|
||||
link, _ := body["link"].(string)
|
||||
if !strings.Contains(link, "token=") {
|
||||
t.Fatalf("link = %q, want one carrying a token", link)
|
||||
}
|
||||
|
||||
// Den venter i panelet under noten, så ejeren kan se, at den er sendt.
|
||||
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
pending, _ := panel["invites"].([]any)
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("want one pending invite under the note, got %v", pending)
|
||||
}
|
||||
|
||||
// Og linket laver kontoen og delingen i ét.
|
||||
token := link[strings.Index(link, "token=")+len("token="):]
|
||||
fresh := newSignedOut(t, owner)
|
||||
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
|
||||
"token": token, "name": "Sofie", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("signup with the note invite: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Noten er hendes at læse — og som medredaktør også at rette.
|
||||
resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode)
|
||||
}
|
||||
if note["title"] != "Aftale om levering" {
|
||||
t.Errorf("the invited person got something else: %v", note)
|
||||
}
|
||||
resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]string{"body": "# Aftale om levering\nfredag kl. 10"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Og invitationen er brugt op: den står ikke længere og venter.
|
||||
_, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
if left, _ := after["invites"].([]any); len(left) != 0 {
|
||||
t.Errorf("the invite is still pending after it was accepted: %v", left)
|
||||
}
|
||||
shares, _ := after["shares"].([]any)
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("want the accepted invite to be a share, got %v", shares)
|
||||
}
|
||||
}
|
||||
|
||||
// En adresse, der viser sig at høre til en konto, er den konto — ikke en
|
||||
// invitation til at oprette en, som personen aldrig kan komme igennem.
|
||||
func TestSharingByEmailFindsAnExistingAccount(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
noteID := owner.createNote(t, "# Prisliste\n2026")
|
||||
|
||||
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "[email protected]"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
person, _ := body["user"].(map[string]any)
|
||||
if person == nil || person["id"] != sofie {
|
||||
t.Fatalf("the response does not say who it reached: %v", body)
|
||||
}
|
||||
if _, invited := body["invited"]; invited {
|
||||
t.Error("an address with an account was invited rather than shared with")
|
||||
}
|
||||
|
||||
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || role != "viewer" {
|
||||
t.Fatalf("share role = %v (%v), want viewer by default", role, err)
|
||||
}
|
||||
}
|
||||
|
||||
// To links til den samme indbakke er ikke en invitation mere: det er to, der gør
|
||||
// det samme, og kun det ene kan trækkes tilbage ad gangen.
|
||||
func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
noteID := owner.createNote(t, "# Bankoplysninger\nkonto")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "[email protected]"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first invite: status %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "[email protected]"})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("second invite: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Trukket tilbage kan den derimod, og så kan der sendes en ny.
|
||||
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
pending, _ := panel["invites"].([]any)
|
||||
id := pending[0].(map[string]any)["id"].(string)
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("withdraw: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "[email protected]"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til
|
||||
// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før.
|
||||
func TestOnlyTheOwnerCanInviteToANote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"email": "[email protected]"})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode)
|
||||
}
|
||||
if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 {
|
||||
t.Fatalf("invites = %v (%v), want none", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// En invitation til én note kan ikke bruges til at rydde en anden notes
|
||||
// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom.
|
||||
func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
mine := owner.createNote(t, "# Min note\n1")
|
||||
other := owner.createNote(t, "# Min anden note\n2")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares",
|
||||
map[string]string{"email": "[email protected]"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Fatal("invite failed")
|
||||
}
|
||||
invites, err := owner.db.ListNoteInvites(t.Context(), mine)
|
||||
if err != nil || len(invites) != 1 {
|
||||
t.Fatalf("invites = %v (%v)", invites, err)
|
||||
}
|
||||
|
||||
resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode)
|
||||
}
|
||||
if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 {
|
||||
t.Error("the invite was withdrawn through a note it does not belong to")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler
|
||||
// noten, og den, der bliver nævnt, får besked om det.
|
||||
func TestMentioningSomebodySharesTheNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nikke noget endnu")
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ret noten: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Delt, som læser.
|
||||
role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || !has || role != "viewer" {
|
||||
t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err)
|
||||
}
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én
|
||||
// handling, og den ene sætning siger mere end den anden.
|
||||
list, unread := notifications(t, other)
|
||||
if len(list) != 1 || unread != 1 {
|
||||
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
|
||||
}
|
||||
if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved
|
||||
// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder.
|
||||
func TestTheSameMentionOnlyCountsOnce(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\n@Sofie kigger")
|
||||
// Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen.
|
||||
if list, _ := notifications(t, other); len(list) != 1 {
|
||||
t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list))
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
}
|
||||
// Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre
|
||||
// omtaler mere.
|
||||
list, _ := notifications(t, other)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list))
|
||||
}
|
||||
if firstOf(t, list)["kind"] != "note.changed" {
|
||||
t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list))
|
||||
}
|
||||
}
|
||||
|
||||
// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den,
|
||||
// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed.
|
||||
func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
editor := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
peterClient := owner.newUser(t, "[email protected]", "Peter")
|
||||
peter := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
|
||||
if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("medredaktøren retter")
|
||||
}
|
||||
|
||||
if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has {
|
||||
t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err)
|
||||
}
|
||||
// Og Peter får ikke besked om en note, han ikke kan åbne.
|
||||
if list, _ := notifications(t, peterClient); len(list) != 0 {
|
||||
t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list))
|
||||
}
|
||||
// Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse.
|
||||
if list, _ := notifications(t, owner); len(list) == 0 {
|
||||
t.Error("ejeren hørte ikke, at medredaktøren skrev i noten")
|
||||
}
|
||||
}
|
||||
|
||||
// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke
|
||||
// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn.
|
||||
func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale\nintet")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
|
||||
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
|
||||
if err != nil || role != "editor" {
|
||||
t.Errorf("rolle = %v (%v), want editor uændret", role, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
}
|
||||
// Regnet forfra, én gang, og læst to veje ud af det samme svar: hvem der har
|
||||
// *denne* note, fordi en anden peger på den, og hvilke noter der omvendt følger
|
||||
// med den her. To udregninger af det samme er to, der kan blive uenige.
|
||||
//
|
||||
// Og forfra frem for læst af basen, så panelet viser det, der gælder nu — også
|
||||
// når linkene er skrevet af en anden og delingerne derfor ikke er regnet om
|
||||
// siden.
|
||||
following, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "sync linked shares", err)
|
||||
return
|
||||
}
|
||||
via := map[string]string{}
|
||||
for _, l := range following {
|
||||
if l.NoteID == n.ID {
|
||||
via[l.UserID] = l.Via
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]noteShareJSON, 0, len(shares))
|
||||
already := map[string]bool{}
|
||||
for _, sh := range shares {
|
||||
// Panelet skal kunne sige forskel: en deling, nogen har valgt, tages tilbage
|
||||
// ved at fjerne den — en, der er fulgt med, ved at fjerne linket eller den
|
||||
// deling, den kom fra. En knap, der lover det første og gør det andet, er
|
||||
// værre end ingen knap.
|
||||
out = append(out, noteShareJSON{
|
||||
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
|
||||
Role: string(sh.Role),
|
||||
Linked: via[sh.User.ID] != "",
|
||||
Via: via[sh.User.ID],
|
||||
})
|
||||
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})
|
||||
}
|
||||
|
||||
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
|
||||
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
|
||||
// den samme indbakke, og ingen af dem til at trække tilbage.
|
||||
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list note invites", err)
|
||||
return
|
||||
}
|
||||
pending := make([]noteInviteJSON, 0, len(invites))
|
||||
for _, i := range invites {
|
||||
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"shares": out,
|
||||
"candidates": cand,
|
||||
"invites": pending,
|
||||
"follows": followsOf(following, n.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én
|
||||
// pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste
|
||||
// følger med" er det samme, hvem det så end deles med.
|
||||
func followsOf(all []store.LinkedShare, noteID string) []followJSON {
|
||||
seen := map[string]bool{}
|
||||
out := []followJSON{}
|
||||
for _, l := range all {
|
||||
if l.ViaID != noteID || seen[l.NoteID] {
|
||||
continue
|
||||
}
|
||||
seen[l.NoteID] = true
|
||||
out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type followJSON struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type noteShareJSON struct {
|
||||
User personJSON `json:"user"`
|
||||
Role string `json:"role"`
|
||||
// Sat, når personen har noten, fordi en anden delt note peger på den — ikke
|
||||
// fordi nogen har delt netop den. `via` er den note, der peger.
|
||||
Linked bool `json:"linked"`
|
||||
Via string `json:"via,omitempty"`
|
||||
}
|
||||
|
||||
type shareNoteRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
// Email deles der med, når personen ikke er at finde i listen — enten fordi
|
||||
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
|
||||
// finde endnu.
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
|
||||
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
|
||||
// dukker op.
|
||||
type noteInviteJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleShareNote grants a person a role on a note, or changes the role they hold.
|
||||
//
|
||||
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
|
||||
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
|
||||
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
|
||||
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
|
||||
//
|
||||
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
|
||||
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
|
||||
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
|
||||
// vent på at de opretter sig, find noten frem, del den.
|
||||
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
|
||||
}
|
||||
|
||||
me := userFrom(r.Context())
|
||||
email := store.NormalizeEmail(req.Email)
|
||||
|
||||
if req.UserID == "" && email == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
// En adresse, der viser sig at høre til en konto, er den konto. At sende dem
|
||||
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en blindgyde
|
||||
// — samme regel som en projektinvitation følger.
|
||||
if req.UserID == "" {
|
||||
if !strings.Contains(email, "@") {
|
||||
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||
return
|
||||
}
|
||||
if email == store.NormalizeEmail(me.Email) {
|
||||
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
|
||||
return
|
||||
}
|
||||
existing, err := s.db.UserByEmail(r.Context(), email)
|
||||
if err != nil {
|
||||
s.inviteToNote(w, r, n, email, role)
|
||||
return
|
||||
}
|
||||
req.UserID = existing.ID
|
||||
}
|
||||
|
||||
// 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 == me.ID {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
|
||||
return
|
||||
}
|
||||
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
|
||||
s.storeError(w, r, "share note", err)
|
||||
return
|
||||
}
|
||||
s.notifyNoteShared(r, n, req.UserID)
|
||||
|
||||
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
|
||||
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
|
||||
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "sync linked shares", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"follows": followsOf(followed, n.ID),
|
||||
// Personen sendes med retur, fordi fladen kan have delt med en adresse og
|
||||
// derfor ikke selv ved, hvem den ramte.
|
||||
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
||||
"role": string(role),
|
||||
})
|
||||
}
|
||||
|
||||
// inviteToNote sends a link that creates an account and the share at once.
|
||||
//
|
||||
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
|
||||
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
|
||||
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
|
||||
// på den.
|
||||
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
|
||||
me := userFrom(r.Context())
|
||||
|
||||
// En anden invitation til den samme note i den samme indbakke er ikke en
|
||||
// invitation mere: det er to links, der gør det samme, og kun det ene kan
|
||||
// trækkes tilbage ad gangen.
|
||||
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
|
||||
if err != nil {
|
||||
s.internal(w, r, "pending note invite", err)
|
||||
return
|
||||
}
|
||||
if pending {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
|
||||
return
|
||||
}
|
||||
|
||||
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create note invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
|
||||
emailed := false
|
||||
if s.mail.Configured() {
|
||||
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
|
||||
s.log.Error("send note invite", "err", err, "to", email)
|
||||
} else {
|
||||
emailed = true
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
|
||||
"link": link,
|
||||
"emailed": emailed,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteNoteInvite withdraws an invitation before it is used.
|
||||
//
|
||||
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
|
||||
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
|
||||
// kan kun rækken, det hører til.
|
||||
func (s *Server) handleDeleteNoteInvite(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.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
|
||||
s.storeError(w, r, "delete note invite", 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
|
||||
}
|
||||
// Det, der fulgte med, følger også med tilbage. Regnet forfra: de udledte rækker
|
||||
// skrives om fra bunden, så en note, der stadig kan nås fra en anden deling,
|
||||
// bliver stående, og en, der ikke kan, forsvinder.
|
||||
if _, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID); err != nil {
|
||||
s.internal(w, r, "sync linked shares", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createNote makes a note as the current client and returns its id.
|
||||
func (ts *testServer) createNote(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create note: status %d", resp.StatusCode)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create note returned no id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func userID(t *testing.T, ts *testServer, email string) string {
|
||||
t.Helper()
|
||||
u, err := ts.db.UserByEmail(t.Context(), email)
|
||||
if err != nil {
|
||||
t.Fatalf("look up %s: %v", email, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// A note shared with a person shows up in their list, marked as theirs-from-
|
||||
// somebody, and a viewer can read it but not change it.
|
||||
func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
|
||||
|
||||
// Before sharing, Sofie cannot see it at all.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("an unshared note was reachable: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Share it as a viewer.
|
||||
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Now it is in Sofie's list, flagged as shared with her and carrying its owner.
|
||||
_, list := other.do(t, "GET", "/api/v1/notes", nil)
|
||||
notes, _ := list["notes"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range notes {
|
||||
n := raw.(map[string]any)
|
||||
if n["id"] == noteID {
|
||||
found = n
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the shared note is not in the recipient's list")
|
||||
}
|
||||
if found["shared_with_me"] != true {
|
||||
t.Errorf("the note is not marked shared_with_me: %v", found)
|
||||
}
|
||||
ownerObj, _ := found["owner"].(map[string]any)
|
||||
if ownerObj == nil || ownerObj["name"] != "Kristian" {
|
||||
t.Errorf("the owner is not carried on the shared note: %v", found["owner"])
|
||||
}
|
||||
|
||||
// A viewer reads it.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("a viewer could not read the shared note: %d", resp.StatusCode)
|
||||
}
|
||||
// But cannot change it.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "hijacked"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a viewer changed a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An editor may change the text, but only the owner may delete the note or hand it
|
||||
// to somebody else.
|
||||
func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Delt\noprindelig")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share as editor failed")
|
||||
}
|
||||
|
||||
// The editor writes.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Delt\nrettet"}); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("an editor could not change a shared note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The editor cannot re-share it: giving access is the owner's alone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an editor could read the share list: %d", resp.StatusCode)
|
||||
}
|
||||
// The editor cannot delete it.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID, nil); resp.StatusCode == http.StatusNoContent {
|
||||
t.Error("an editor deleted the owner's note")
|
||||
}
|
||||
// The note still exists for the owner.
|
||||
if resp, _ := owner.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("the note is gone after the editor's delete attempt: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsharing takes access away again, and only the owner can do it.
|
||||
func TestUnshareRemovesAccess(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Hemmelig")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
// A non-owner cannot unshare.
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a non-owner unshared a note: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The owner can.
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("owner unshare: %d", resp.StatusCode)
|
||||
}
|
||||
// And access is gone.
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the note was still reachable after unsharing: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The share list is owner-only and offers the instance's other people as
|
||||
// candidates, minus those already on the note.
|
||||
func TestShareListShowsCandidatesAndIsOwnerOnly(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
owner.newUser(t, "[email protected]", "Sofie")
|
||||
owner.newUser(t, "[email protected]", "Anders")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Plan")
|
||||
owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
|
||||
_, out := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
|
||||
shares, _ := out["shares"].([]any)
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("shares = %d, want 1", len(shares))
|
||||
}
|
||||
cands, _ := out["candidates"].([]any)
|
||||
// Anders is a candidate; Sofie is already on the note and so is not.
|
||||
names := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
names[c.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
if !names["Anders"] {
|
||||
t.Errorf("Anders is not offered as a candidate: %v", names)
|
||||
}
|
||||
if names["Sofie"] {
|
||||
t.Error("somebody already on the note is offered as a candidate")
|
||||
}
|
||||
if names["Kristian"] {
|
||||
t.Error("the owner is offered as a candidate to share with themselves")
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing refuses a made-up person and the owner themselves.
|
||||
func TestShareRejectsUnknownAndSelf(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
me := userID(t, owner, "[email protected]")
|
||||
noteID := owner.createNote(t, "# Note")
|
||||
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": "does-not-exist", "role": "viewer"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("sharing with an unknown id: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "viewer"}); resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
|
||||
t.Error("a note was shared at the owner role")
|
||||
}
|
||||
}
|
||||
|
||||
// De noter, en delt note peger på, følger med — og holder op igen.
|
||||
//
|
||||
// Det var meldt som en fejl den anden vej rundt: linkene i en delt note førte
|
||||
// ingen steder hen, fordi det, de pegede på, ikke var delt. Prøven går hele vejen
|
||||
// rundt om svaret på det — dybden, teksten der ændrer sig bagefter, det der ikke
|
||||
// må følge med, og vejen tilbage.
|
||||
func TestSharedNoteTakesItsLinksWithIt(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
// Prisliste peger videre på Leveringstider, så kæden er to led lang.
|
||||
deep := owner.createNote(t, "# Leveringstider\nfire dage")
|
||||
linked := owner.createNote(t, "# Prisliste\nog [[Leveringstider]]")
|
||||
apart := owner.createNote(t, "# Bankoplysninger\nikke til deling")
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nse [[Prisliste]]")
|
||||
|
||||
reachable := func(id string) bool {
|
||||
resp, _ := other.do(t, "GET", "/api/v1/notes/"+id, nil)
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
resp, out := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("share: status %d", resp.StatusCode)
|
||||
}
|
||||
// Svaret siger, hvad der fulgte med — hele kæden, ikke kun første led. En
|
||||
// deling, der stille tog to noter mere med, ville være rigtig og alligevel ikke
|
||||
// til at overskue.
|
||||
follows, _ := out["follows"].([]any)
|
||||
if len(follows) != 2 {
|
||||
t.Fatalf("delingen nævnte %d noter, der fulgte med, want 2: %v", len(follows), out["follows"])
|
||||
}
|
||||
|
||||
if !reachable(linked) {
|
||||
t.Error("noten, den delte note peger på, fulgte ikke med")
|
||||
}
|
||||
if !reachable(deep) {
|
||||
t.Error("kæden stoppede efter ét led")
|
||||
}
|
||||
if reachable(apart) {
|
||||
t.Error("en note, ingen peger på, blev delt")
|
||||
}
|
||||
|
||||
// Skrevet bagefter tæller også. Et link i dag i en note delt i går skal føre
|
||||
// samme sted hen for begge.
|
||||
senere := owner.createNote(t, "# Rabatter\nti procent")
|
||||
if reachable(senere) {
|
||||
t.Fatal("en helt ny note var delt, før noget pegede på den")
|
||||
}
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nse [[Prisliste]] og [[Rabatter]]"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("kunne ikke skrive videre i den delte note")
|
||||
}
|
||||
if !reachable(senere) {
|
||||
t.Error("et link skrevet efter delingen fulgte ikke med")
|
||||
}
|
||||
|
||||
// Og vejen tilbage: tages delingen tilbage, følger det med, den tog med sig.
|
||||
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unshare: status %d", resp.StatusCode)
|
||||
}
|
||||
if reachable(linked) || reachable(deep) || reachable(senere) {
|
||||
t.Error("det, der fulgte med delingen, blev stående, da den blev taget tilbage")
|
||||
}
|
||||
}
|
||||
|
||||
// En medredaktør kan ikke dele ejerens noter med sig selv ved at skrive et link.
|
||||
//
|
||||
// Det er den ene vej, "linkene følger med" kunne blive til en rettighedsfejl: et
|
||||
// link peger på en titel, og enhver, der må rette i en delt note, kan skrive en
|
||||
// titel. Derfor regnes delingerne kun om, når ejeren selv har gemt.
|
||||
func TestAnEditorCannotShareTheOwnersNotesWithThemselves(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
secret := owner.createNote(t, "# Bankoplysninger\nreg og konto")
|
||||
noteID := owner.createNote(t, "# Fælles\nintet link her")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("share as editor failed")
|
||||
}
|
||||
|
||||
// Hun skriver linket selv, i den note hun har fået lov at rette i.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Fælles\nse [[Bankoplysninger]]"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("medredaktøren kunne ikke skrive")
|
||||
}
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+secret, nil); resp.StatusCode == http.StatusOK {
|
||||
t.Error("en medredaktør delte ejerens note med sig selv ved at skrive et link")
|
||||
}
|
||||
}
|
||||
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
// The list carries a preview, not the whole note — see briefly(). The body is
|
||||
// fetched by id, which is what the interface does when it opens one.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
noteID := notes[0].(map[string]any)["id"].(string)
|
||||
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
|
||||
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
|
||||
}
|
||||
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
body := whole["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/push"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -15,6 +19,7 @@ type notificationJSON struct {
|
||||
ActorName string `json:"actor_name,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
NoteID string `json:"note_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
@@ -40,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
for _, n := range list {
|
||||
out = append(out, notificationJSON{
|
||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
||||
@@ -71,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
|
||||
}
|
||||
|
||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
|
||||
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// Detached from the request: a push round trip to Google or Mozilla has no
|
||||
@@ -98,47 +104,308 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
|
||||
}
|
||||
}
|
||||
|
||||
// notifyAssigned tells somebody a task is now theirs.
|
||||
//
|
||||
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
|
||||
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
|
||||
// hvad de lige har gjort.
|
||||
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
|
||||
if t == nil || t.AssigneeID == "" {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
|
||||
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyNoteShared tells somebody that a note is now theirs to read.
|
||||
//
|
||||
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
|
||||
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
|
||||
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
|
||||
//
|
||||
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
|
||||
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
|
||||
// ikke en ny note at læse.
|
||||
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
|
||||
if n == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyProjectShared tells somebody that a project is now theirs to work in.
|
||||
//
|
||||
// Indtil nu skete det i tavshed. En note delt med dig gav en besked; et projekt gav
|
||||
// ingenting — det stod bare i sidebjælken næste gang, du kiggede, uden at noget
|
||||
// sagde hvornår eller fra hvem. Et projekt er mere end en note: der kan lægges
|
||||
// opgaver til dig i det.
|
||||
//
|
||||
// Én besked, når delingen sker. En rolle, der ændres bagefter, er ikke et nyt
|
||||
// projekt at åbne, og en invitation, der bliver taget imod, er modtagerens egen
|
||||
// handling — de skal ikke have besked om det, de lige selv gjorde.
|
||||
func (s *Server) notifyProjectShared(r *http.Request, project *store.Project, userID string) {
|
||||
if project == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
if actor.ID == userID {
|
||||
return
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actor.ID, ProjectID: project.ID,
|
||||
Kind: "project.shared", Title: actor.Name + " delte et projekt med dig", Body: project.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
|
||||
// it — folded into one line per note while it is unread.
|
||||
//
|
||||
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
|
||||
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
|
||||
//
|
||||
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
|
||||
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
|
||||
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
|
||||
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
actor := userFrom(r.Context())
|
||||
audience, err := s.db.NoteAudience(r.Context(), n.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
|
||||
return
|
||||
}
|
||||
title := actor.Name + " rettede en note"
|
||||
body := n.Title
|
||||
for _, userID := range audience {
|
||||
if userID == actor.ID || except[userID] {
|
||||
continue
|
||||
}
|
||||
// Fold first, and only write a row when there was nothing to fold into.
|
||||
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
|
||||
// hvor klokken siger to.
|
||||
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
|
||||
if err != nil {
|
||||
s.log.Warn("fold note notification", "err", err, "note", n.ID)
|
||||
continue
|
||||
}
|
||||
if folded {
|
||||
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
|
||||
// op og siger nu et andet navn. Uden det her opdager en åben fane først
|
||||
// den sammenlagte rettelse ved næste genindlæsning.
|
||||
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
|
||||
continue
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
|
||||
Kind: "note.changed", Title: title, Body: body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||
// which has the schedule but not the keys.
|
||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
s.pushToUser(userID, title, body, projectID)
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
// pushOutcome is what happened, per device.
|
||||
//
|
||||
// It exists because "nothing arrives" was unanswerable. A push that a service
|
||||
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
|
||||
// service will not take, a browser that revoked permission without telling the
|
||||
// server — and every one of them looked exactly like silence.
|
||||
type pushOutcome struct {
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
Sent int `json:"sent"`
|
||||
Failed []pushFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// pushFailure names the service and quotes what it said.
|
||||
//
|
||||
// The host and not the endpoint: the rest of that URL is the credential the push
|
||||
// service issued for this device, and it does not belong in an answer, a log line
|
||||
// or a screenshot.
|
||||
type pushFailure struct {
|
||||
Service string `json:"service"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
var out pushOutcome
|
||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return
|
||||
return out
|
||||
}
|
||||
out.Subscriptions = len(subs)
|
||||
|
||||
public, private, err := s.vapidKeys(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("vapid keys", "err", err)
|
||||
return
|
||||
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
|
||||
return out
|
||||
}
|
||||
|
||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||
target := "/projekt/" + projectID
|
||||
if projectID == "" {
|
||||
target = "/"
|
||||
}
|
||||
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
|
||||
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
|
||||
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
|
||||
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
|
||||
// stadig en besked.
|
||||
unread, err := s.db.UnreadNotificationCount(ctx, userID)
|
||||
if err != nil {
|
||||
s.log.Warn("unread count for push", "err", err)
|
||||
}
|
||||
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
|
||||
subject := s.vapidSubject()
|
||||
for _, sub := range subs {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||
}, payload, push.VAPID{
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
Public: public, Private: private, Subject: subject,
|
||||
})
|
||||
if err == nil {
|
||||
out.Sent++
|
||||
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||
s.log.Warn("mark push delivered", "err", delErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
|
||||
// Warn, not Debug.
|
||||
//
|
||||
// This was the one line that would have said why no notification ever
|
||||
// arrived, and at Debug it is not printed on an instance running at the
|
||||
// default level. A delivery that fails silently and a delivery that does
|
||||
// not happen look the same from the outside, and the difference is the
|
||||
// whole diagnosis.
|
||||
reason := pushReason(sub.Endpoint, err)
|
||||
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
|
||||
out.Failed = append(out.Failed, pushFailure{
|
||||
Service: pushHost(sub.Endpoint),
|
||||
Reason: reason,
|
||||
})
|
||||
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
||||
s.log.Warn("record push failure", "err", recErr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushReason is what went wrong, with the endpoint taken back out of it.
|
||||
//
|
||||
// Go wraps a transport failure as `*url.Error`, and its message is `Post
|
||||
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
|
||||
// endpoint, which is the credential the push service issued for that device. It
|
||||
// would have gone into the answer, the log line and any screenshot of either. A
|
||||
// test written to prove the host was reported instead is what found it.
|
||||
//
|
||||
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
|
||||
// anyway: the next error type to carry it will not come with a warning.
|
||||
func pushReason(endpoint string, err error) string {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||
err = urlErr.Err
|
||||
}
|
||||
reason := err.Error()
|
||||
if endpoint != "" {
|
||||
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// pushHost is the service an endpoint belongs to, and nothing else from it.
|
||||
func pushHost(endpoint string) string {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
return "ukendt"
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
// handlePushTest sends one notification to this account's devices and says what
|
||||
// happened to it.
|
||||
//
|
||||
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
|
||||
// settings page said "on for this device" as soon as a subscription existed, and a
|
||||
// refusal from the push service went into a Debug line nobody sees. There was no
|
||||
// way to tell "the browser never subscribed" from "the service refused it" from
|
||||
// "nothing has come due yet" — three different problems with one appearance.
|
||||
//
|
||||
// It goes through the same path as a real notification rather than a shortcut, so
|
||||
// what it proves is the thing that actually has to work.
|
||||
//
|
||||
// The words come from the client, which is where the dictionaries are. A server
|
||||
// that carries its own copy of "This is a test" carries it in one language, and
|
||||
// this one deliberately keeps its prose in the interface — see
|
||||
// `internal/i18n`. The text is only ever shown back to the person who asked for
|
||||
// it, on their own devices.
|
||||
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
title = "verdande"
|
||||
}
|
||||
// Bounded: this is written into a notification on somebody's lock screen, and
|
||||
// a megabyte of title is not a notification.
|
||||
if len(title) > 200 {
|
||||
title = title[:200]
|
||||
}
|
||||
body := strings.TrimSpace(req.Body)
|
||||
if len(body) > 500 {
|
||||
body = body[:500]
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
|
||||
}
|
||||
|
||||
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||
}
|
||||
|
||||
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
||||
//
|
||||
// It has to be one the service will actually accept, and Apple is the strict one:
|
||||
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
||||
// what the old fallback, mailto:[email protected], was on an instance with
|
||||
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
||||
// the person waiting for the notification could tell.
|
||||
//
|
||||
// So a real address is used when there is one, and otherwise the instance's own
|
||||
// https address — which is a valid VAPID subject, points at something that exists,
|
||||
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
||||
// the https URL a 201.
|
||||
func (s *Server) vapidSubject() string {
|
||||
from := strings.TrimSpace(s.cfg.SMTP.From)
|
||||
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
||||
return "mailto:" + from
|
||||
}
|
||||
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
||||
return s.cfg.BaseURL
|
||||
}
|
||||
return "mailto:[email protected]"
|
||||
}
|
||||
|
||||
// --- subscription endpoints -----------------------------------------------------------
|
||||
|
||||
type pushSubscriptionRequest struct {
|
||||
@@ -170,6 +437,23 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The endpoint is an address this server will POST to, on a schedule the
|
||||
// caller chooses — set a reminder a minute out and the push job fetches it,
|
||||
// again every minute. Unchecked, that is a way to make the instance reach
|
||||
// anything it can route to, on demand.
|
||||
//
|
||||
// https only: a push service is on the public internet and speaks TLS. Nothing
|
||||
// legitimate needs http here, and allowing it is the difference between a
|
||||
// blind request and a readable one.
|
||||
if !strings.HasPrefix(req.Endpoint, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": "must be an https address"})
|
||||
return
|
||||
}
|
||||
if reason := safedial.CheckURL(req.Endpoint); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": reason})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
||||
UserID: userFrom(r.Context()).ID,
|
||||
Endpoint: req.Endpoint,
|
||||
@@ -189,7 +473,10 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||
// Scoped to the caller. It was not, so anybody who learned somebody else's
|
||||
// endpoint could unsubscribe their device — a small thing to do to somebody
|
||||
// and no reason at all to be able to.
|
||||
if err := s.db.DeletePushSubscription(r.Context(), userFrom(r.Context()).ID, req.Endpoint); err != nil {
|
||||
s.internal(w, r, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// notifications reads somebody's bell.
|
||||
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list notifications: status %d", resp.StatusCode)
|
||||
}
|
||||
list, _ := out["notifications"].([]any)
|
||||
unread, _ := out["unread"].(float64)
|
||||
return list, unread
|
||||
}
|
||||
|
||||
func firstOf(t *testing.T, list []any) map[string]any {
|
||||
t.Helper()
|
||||
if len(list) == 0 {
|
||||
t.Fatal("ingen beskeder")
|
||||
}
|
||||
n, _ := list[0].(map[string]any)
|
||||
return n
|
||||
}
|
||||
|
||||
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
|
||||
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||
projectID, _ := project["id"].(string)
|
||||
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
|
||||
// medlem med det samme.
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "[email protected]", "role": "editor"}); resp.StatusCode >= 300 {
|
||||
t.Fatalf("invitér: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, task := owner.do(t, "POST", "/api/v1/tasks",
|
||||
map[string]any{"project_id": projectID, "content": "ring til revisor"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("opret opgave: %d", resp.StatusCode)
|
||||
}
|
||||
taskID, _ := task["id"].(string)
|
||||
|
||||
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
|
||||
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
|
||||
before, _ := notifications(t, other)
|
||||
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tildel: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
list, _ := notifications(t, other)
|
||||
if len(list) != len(before)+1 {
|
||||
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
|
||||
}
|
||||
n := firstOf(t, list)
|
||||
if n["kind"] != "assigned" {
|
||||
t.Errorf("kind = %v, want assigned", n["kind"])
|
||||
}
|
||||
if n["task_id"] != taskID {
|
||||
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
|
||||
}
|
||||
if n["actor_name"] != "Kristian" {
|
||||
t.Errorf("actor_name = %v", n["actor_name"])
|
||||
}
|
||||
|
||||
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
|
||||
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
|
||||
// ændring, gør — og så bliver klokken ubrugelig på en dag.
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ret: %d", resp.StatusCode)
|
||||
}
|
||||
if now, _ := notifications(t, other); len(now) != len(before)+1 {
|
||||
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
|
||||
}
|
||||
|
||||
// Og den, der tildeler sig selv, får ingenting.
|
||||
me := userID(t, owner, "[email protected]")
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
|
||||
}
|
||||
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
|
||||
// så længe den ikke er læst.
|
||||
func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
sofie := userID(t, owner, "[email protected]")
|
||||
|
||||
noteID := owner.createNote(t, "# Aftale om levering\nfirst")
|
||||
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
|
||||
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("del noten")
|
||||
}
|
||||
|
||||
// Selve delingen er den første besked: Sofie skal vide, at noten er der, før
|
||||
// nogen retter i den. Den tælles med i alt, hvad der følger.
|
||||
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 ||
|
||||
firstOf(t, list)["kind"] != "note.shared" {
|
||||
t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list)
|
||||
}
|
||||
|
||||
// Ejeren skriver. Sofie skal høre det.
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
list, unread := notifications(t, other)
|
||||
if unread != 2 || len(list) != 2 {
|
||||
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
|
||||
len(list), unread)
|
||||
}
|
||||
n := firstOf(t, list)
|
||||
if n["kind"] != "note.changed" || n["note_id"] != noteID {
|
||||
t.Errorf("besked = %v", n)
|
||||
}
|
||||
|
||||
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
|
||||
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
|
||||
// ens linjer i klokken.
|
||||
for i := 0; i < 3; i++ {
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten igen")
|
||||
}
|
||||
}
|
||||
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
|
||||
t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse",
|
||||
len(list))
|
||||
}
|
||||
|
||||
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
|
||||
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatal("markér som læst")
|
||||
}
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten efter læsningen")
|
||||
}
|
||||
if list, unread := notifications(t, other); len(list) != 3 || unread != 1 {
|
||||
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread)
|
||||
}
|
||||
|
||||
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
|
||||
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
|
||||
}
|
||||
|
||||
// Og den anden vej: Sofie skriver, ejeren hører det.
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("Sofie retter")
|
||||
}
|
||||
list, _ = notifications(t, owner)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
|
||||
}
|
||||
if firstOf(t, list)["actor_name"] != "Sofie" {
|
||||
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
|
||||
}
|
||||
|
||||
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("stjernemarkér")
|
||||
}
|
||||
if list, _ := notifications(t, other); len(list) != 3 {
|
||||
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// En note, ingen deler, er ingen at give besked.
|
||||
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
|
||||
owner := newTestServer(t)
|
||||
owner.bootstrap(t)
|
||||
other := owner.newUser(t, "[email protected]", "Sofie")
|
||||
|
||||
noteID := owner.createNote(t, "# Min egen\nintet")
|
||||
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
|
||||
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("ret noten")
|
||||
}
|
||||
if list, _ := notifications(t, other); len(list) != 0 {
|
||||
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
|
||||
}
|
||||
if list, _ := notifications(t, owner); len(list) != 0 {
|
||||
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||
}
|
||||
|
||||
// handleReorderSections sets the order of the sections inside one project.
|
||||
//
|
||||
// Under /projects/{projectID}/ rather than beside the other section routes, which
|
||||
// hang off a section id: the thing being changed here is the project's list, not
|
||||
// any one section in it. That also puts it behind the same project permission
|
||||
// check as everything else in this subtree, instead of resolving a section to its
|
||||
// project one id at a time and hoping they all named the same one.
|
||||
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// The same ceiling as the projects list, and a bound on the transaction rather
|
||||
// than an opinion about how many sections a project may have.
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
|
||||
s.internal(w, r, "reorder sections", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.updated", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// sectionAccess resolves a section to its project and checks the caller may edit
|
||||
// it. Sections have no permissions of their own — they belong to a project, and
|
||||
// that is where access is decided.
|
||||
@@ -424,22 +452,100 @@ type memberJSON struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
|
||||
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
|
||||
// dukker op.
|
||||
type projectInviteJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleListMembers is everything the share panel needs, in one answer: who is on
|
||||
// the project, who can be added, and who has been invited and not arrived.
|
||||
//
|
||||
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
|
||||
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
|
||||
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
|
||||
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
|
||||
//
|
||||
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
|
||||
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
|
||||
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
|
||||
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
|
||||
members, err := s.db.ListMembers(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list members", err)
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
already := map[string]bool{}
|
||||
for _, m := range members {
|
||||
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
||||
already[m.UserID] = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"members": out})
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
already[project.OwnerID] = true
|
||||
|
||||
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "share candidates", err)
|
||||
return
|
||||
}
|
||||
candidates := make([]personJSON, 0, len(people))
|
||||
for _, p := range people {
|
||||
if already[p.ID] {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
||||
}
|
||||
|
||||
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list project invites", err)
|
||||
return
|
||||
}
|
||||
pending := make([]projectInviteJSON, 0, len(invites))
|
||||
for _, i := range invites {
|
||||
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"members": out,
|
||||
"candidates": candidates,
|
||||
"pending": pending,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
|
||||
//
|
||||
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
|
||||
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
|
||||
// udløber, med et link, der virker hele tiden.
|
||||
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
|
||||
if err != nil {
|
||||
s.storeError(w, r, "delete project invite", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type inviteRequest struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
// UserID er den, man har peget på i listen. Email er den, man kender adressen
|
||||
// på — og den er også den, der virker, når der ingen konto er endnu.
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type inviteResponse struct {
|
||||
@@ -450,8 +556,14 @@ type inviteResponse struct {
|
||||
Emailed bool `json:"emailed"`
|
||||
}
|
||||
|
||||
// handleInvite shares a project with somebody. An existing user is added directly;
|
||||
// anybody else gets a link that creates their account and their membership at once.
|
||||
// handleInvite shares a project with somebody.
|
||||
//
|
||||
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
|
||||
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
|
||||
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
|
||||
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
|
||||
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
|
||||
// kontoen og medlemskabet på én gang.
|
||||
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var req inviteRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
@@ -468,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||
return
|
||||
}
|
||||
|
||||
email := store.NormalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
if req.UserID == "" && email == "" {
|
||||
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == "" && !strings.Contains(email, "@") {
|
||||
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||
return
|
||||
}
|
||||
@@ -480,19 +597,50 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Somebody who already has an account is added straight away: sending them
|
||||
// through a signup form they cannot complete would be a dead end.
|
||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||
if existing.ID == project.OwnerID {
|
||||
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
|
||||
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
|
||||
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
|
||||
if req.UserID == "" {
|
||||
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||
req.UserID = existing.ID
|
||||
}
|
||||
}
|
||||
|
||||
if req.UserID != "" {
|
||||
if req.UserID == project.OwnerID {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
||||
return
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
|
||||
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
|
||||
// tom plads, ingen kan fjerne.
|
||||
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
||||
return
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
|
||||
s.internal(w, r, "add member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
|
||||
// de kiggede — uden at noget sagde hvornår eller fra hvem.
|
||||
s.notifyProjectShared(r, project, person.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"added": true,
|
||||
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
||||
// og derfor ikke selv ved, hvem den ramte.
|
||||
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
||||
"role": string(role),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Et andet link til den samme indbakke hjælper ingen: det første virker
|
||||
// stadig, og nu er der to at holde styr på og to at trække tilbage.
|
||||
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -562,15 +710,33 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
|
||||
// anybody removing themselves.
|
||||
//
|
||||
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
|
||||
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
|
||||
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
|
||||
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
|
||||
//
|
||||
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
|
||||
// overdragelse, og den findes ikke endnu.
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
userID := chi.URLParam(r, "userID")
|
||||
me := userFrom(r.Context())
|
||||
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
|
||||
// persons medlemskab, bekræfter at medlemskabet findes.
|
||||
if me.ID != owner && userID != me.ID {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"the owner cannot be removed from their own project")
|
||||
|
||||
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sections can be dragged into another order, and the order is the project's.
|
||||
//
|
||||
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
|
||||
// order the work happens in — and until this endpoint existed the only way to
|
||||
// change it was to delete a section and make it again, which moves its tasks to
|
||||
// the unsectioned area on the way past.
|
||||
func TestSectionsCanBeReordered(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
|
||||
id := project["id"].(string)
|
||||
|
||||
ids := map[string]string{}
|
||||
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
|
||||
for _, name := range names {
|
||||
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
|
||||
ids[name] = body["id"].(string)
|
||||
}
|
||||
|
||||
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
|
||||
order := make([]string, len(want))
|
||||
for i, name := range want {
|
||||
order[i] = ids[name]
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||
map[string]any{"ids": order})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
|
||||
got, _ := listed["sections"].([]any)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
|
||||
}
|
||||
for i, raw := range got {
|
||||
if name := raw.(map[string]any)["name"]; name != want[i] {
|
||||
t.Errorf("section %d is %v, want %q", i, name, want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// A section belonging to somebody else's project is not moved by naming it
|
||||
// here. The ids in the body are not checked one by one — the WHERE clause is
|
||||
// what refuses them — so this is the assertion that it actually does.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
|
||||
otherID := other["id"].(string)
|
||||
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
|
||||
map[string]any{"name": "Havearbejde"})
|
||||
foreignID := foreign["id"].(string)
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
|
||||
map[string]any{"ids": []string{foreignID}})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
|
||||
elsewhere, _ := stillThere["sections"].([]any)
|
||||
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
|
||||
t.Errorf("the other project's section did not survive: %v", elsewhere)
|
||||
}
|
||||
}
|
||||
|
||||
// A project can be created straight into a group.
|
||||
//
|
||||
// `group_id` was declared on the request type, documented on it, and applied by
|
||||
@@ -421,3 +486,239 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
|
||||
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
|
||||
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
|
||||
// beskytte ved at lade være med at vise dem.
|
||||
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
anden, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
// Først står de i listen over dem, der kan tilføjes.
|
||||
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if !hasPerson(before["candidates"], anden.ID) {
|
||||
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
|
||||
}
|
||||
|
||||
// Delt ved at pege, ikke ved at stave.
|
||||
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": anden.ID, "role": "editor"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, added)
|
||||
}
|
||||
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
|
||||
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
|
||||
t.Errorf("user = %v, want Anden", added["user"])
|
||||
}
|
||||
|
||||
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
|
||||
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
|
||||
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if hasPerson(after["candidates"], anden.ID) {
|
||||
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
|
||||
}
|
||||
found := false
|
||||
for _, raw := range after["members"].([]any) {
|
||||
if m := raw.(map[string]any); m["user_id"] == anden.ID {
|
||||
found = true
|
||||
if m["role"] != "editor" {
|
||||
t.Errorf("role = %v, want editor", m["role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Anden blev ikke medlem: %v", after["members"])
|
||||
}
|
||||
}
|
||||
|
||||
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
|
||||
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||
if hasPerson(body["candidates"], me.ID) {
|
||||
t.Errorf("ejeren står som kandidat til sit eget projekt")
|
||||
}
|
||||
}
|
||||
|
||||
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
|
||||
//
|
||||
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
|
||||
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
|
||||
// godt som det første, og så er der to at trække tilbage i stedet for et.
|
||||
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "[email protected]", "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invitation: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
pending, _ := body["pending"].([]any)
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
|
||||
}
|
||||
first := pending[0].(map[string]any)
|
||||
if first["email"] != "[email protected]" {
|
||||
t.Errorf("email = %v", first["email"])
|
||||
}
|
||||
|
||||
// Et andet link til den samme indbakke hjælper ingen.
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "[email protected]", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "DELETE",
|
||||
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if p, _ := after["pending"].([]any); len(p) != 0 {
|
||||
t.Errorf("invitationen står der stadig: %v", after["pending"])
|
||||
}
|
||||
}
|
||||
|
||||
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
|
||||
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
|
||||
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// hasPerson siger, om en liste af personer indeholder et bestemt id.
|
||||
func hasPerson(raw any, id string) bool {
|
||||
list, _ := raw.([]any)
|
||||
for _, item := range list {
|
||||
if p, ok := item.(map[string]any); ok && p["id"] == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Man bliver lukket ind i et projekt uden at blive spurgt. Så skal man i det
|
||||
// mindste vide det — og kunne gå ud igen uden at bede ejeren om lov.
|
||||
func TestBeingAddedToAProjectIsToldAndCanBeUndone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
anden := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
andenUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælleshuset"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": andenUser.ID, "role": "editor"})
|
||||
|
||||
// Beskeden. Før stod projektet bare i sidebjælken næste gang, de kiggede.
|
||||
_, inbox := anden.do(t, "GET", "/api/v1/notifications", nil)
|
||||
var shared map[string]any
|
||||
for _, raw := range inbox["notifications"].([]any) {
|
||||
if n := raw.(map[string]any); n["kind"] == "project.shared" {
|
||||
shared = n
|
||||
}
|
||||
}
|
||||
if shared == nil {
|
||||
t.Fatalf("ingen besked om delingen: %v", inbox["notifications"])
|
||||
}
|
||||
if shared["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %s — uden det fører beskeden ingen steder hen",
|
||||
shared["project_id"], projectID)
|
||||
}
|
||||
|
||||
// Udgangen.
|
||||
resp, _ := anden.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+andenUser.ID, nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("forlad projektet: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := anden.do(t, "GET", "/api/v1/projects/"+projectID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("projektet er stadig synligt efter at være forladt: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// En udgang for en selv er ikke en dør til de andres medlemskaber.
|
||||
func TestAMemberCannotRemoveAnotherMember(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
en := ts.newUser(t, "[email protected]", "En")
|
||||
ts.newUser(t, "[email protected]", "To")
|
||||
|
||||
enUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
for _, u := range []string{enUser.ID, toUser.ID} {
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": u, "role": "editor"})
|
||||
}
|
||||
|
||||
// 404 og ikke 403: "det må du ikke" bekræfter, at medlemskabet findes.
|
||||
resp, _ := en.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+toUser.ID, nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if len(members["members"].([]any)) != 3 {
|
||||
t.Errorf("%d medlemmer tilbage, want 3", len(members["members"].([]any)))
|
||||
}
|
||||
}
|
||||
|
||||
// Ejeren kan ikke gå fra sit eget projekt. Det ville være en overdragelse.
|
||||
func TestTheOwnerCannotLeaveTheirOwnProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
|
||||
resp, _ := ts.do(t, "DELETE",
|
||||
"/api/v1/projects/"+project["id"].(string)+"/members/"+me.ID, nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||
// every push to an Apple device was refused. With no real address, the instance's
|
||||
// own https URL is used instead — valid, real, and accepted.
|
||||
func TestVAPIDSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
from string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{"a real address is used as-is", "[email protected]", "https://verdande.example.dk", "mailto:[email protected]"},
|
||||
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
|
||||
if got := s.vapidSubject(); got != tc.want {
|
||||
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// realIP rewrites r.RemoteAddr to the client address a trusted proxy reported,
|
||||
// and does nothing at all when the peer is not trusted.
|
||||
//
|
||||
// This replaces chi's middleware.RealIP, which is deprecated precisely because it
|
||||
// believes a forwarded header from anyone: an attacker sets X-Forwarded-For to a
|
||||
// new value on every request and gets a fresh rate-limit bucket each time, and
|
||||
// writes whatever address they like into the audit log. The one control that
|
||||
// exists for when a password is already gone is keyed on this address, so it has
|
||||
// to be an address the caller cannot choose.
|
||||
//
|
||||
// The gate is the peer. A header is read only when the machine that opened the
|
||||
// connection is one of cfg.TrustedProxies — a reverse proxy or tunnel the operator
|
||||
// put there. A connection straight off the internet is trusted for nothing, so its
|
||||
// header is ignored and it is keyed on the address it actually came from. Behind a
|
||||
// proxy that overwrites the client's own header (nginx, Caddy and cloudflared all
|
||||
// do) the value is then the real client and cannot be forged from outside.
|
||||
func realIP(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
trusted := cfg.TrustedProxies
|
||||
header := cfg.RealIPHeader
|
||||
if header == "" {
|
||||
header = "X-Forwarded-For"
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ip := trustedClientIP(r, trusted, header); ip != "" {
|
||||
// Port preserved off the original peer only for shape; downstream
|
||||
// clientIP() splits it away, and the forwarded value carries none.
|
||||
r.RemoteAddr = ip
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// trustedClientIP returns the forwarded client address if the peer may be believed,
|
||||
// or "" to leave RemoteAddr untouched.
|
||||
func trustedClientIP(r *http.Request, trusted []netip.Prefix, header string) string {
|
||||
peer := r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(peer); err == nil {
|
||||
peer = host
|
||||
}
|
||||
peerAddr, err := netip.ParseAddr(peer)
|
||||
if err != nil || !isTrusted(peerAddr, trusted) {
|
||||
return ""
|
||||
}
|
||||
|
||||
raw := r.Header.Get(header)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// The leftmost entry is the original client. That entry is only as trustworthy
|
||||
// as the proxy that wrote it — but the trusted proxy is exactly the one that
|
||||
// replaces a client-supplied header, so on the deployments this is written for
|
||||
// it is the real client, and on a direct connection we never reach this line.
|
||||
first := raw
|
||||
if i := strings.IndexByte(raw, ','); i >= 0 {
|
||||
first = raw[:i]
|
||||
}
|
||||
first = strings.TrimSpace(first)
|
||||
if _, err := netip.ParseAddr(first); err != nil {
|
||||
return ""
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func isTrusted(addr netip.Addr, trusted []netip.Prefix) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
for _, p := range trusted {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// The address the rate limiter keys on must be one the caller cannot choose. A
|
||||
// forwarded header is believed only from a trusted proxy, and ignored from a
|
||||
// stranger connecting directly — the difference between a control and a suggestion.
|
||||
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seen := ""
|
||||
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen = clientIP(r)
|
||||
}))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// A private peer is a proxy the operator put there: believe it.
|
||||
name: "trusted proxy is believed",
|
||||
remoteAddr: "10.1.2.3:5000",
|
||||
xff: "203.0.113.9",
|
||||
want: "203.0.113.9",
|
||||
},
|
||||
{
|
||||
// A public peer connected straight to us; its header is a guess.
|
||||
name: "public peer header ignored",
|
||||
remoteAddr: "198.51.100.7:5000",
|
||||
xff: "203.0.113.9",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
{
|
||||
// No header from a trusted proxy leaves the peer as the answer.
|
||||
name: "trusted peer without header",
|
||||
remoteAddr: "127.0.0.1:5000",
|
||||
xff: "",
|
||||
want: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
// A spoofed value from a stranger cannot become the key.
|
||||
name: "spoof from outside does not win",
|
||||
remoteAddr: "198.51.100.7:5000",
|
||||
xff: "127.0.0.1",
|
||||
want: "198.51.100.7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
|
||||
r.RemoteAddr = tc.remoteAddr
|
||||
if tc.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", tc.xff)
|
||||
}
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if seen != tc.want {
|
||||
t.Errorf("keyed on %q, want %q", seen, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "none" turns the trust off entirely, for an instance exposed directly.
|
||||
func TestRealIPTrustNone(t *testing.T) {
|
||||
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := ""
|
||||
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen = clientIP(r)
|
||||
}))
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
if seen != "10.1.2.3" {
|
||||
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,16 @@ type Server struct {
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
loginLimiter *limiter
|
||||
// Sat kun af prøver; se icsClient().
|
||||
icsFetch *http.Client
|
||||
mailLimiter *limiter
|
||||
hookLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
// the ordinary case — and a six-digit code is only safe while guesses are
|
||||
// scarce, so the scarcity has to be counted against the thing being attacked.
|
||||
totpLimiter *limiter
|
||||
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
@@ -80,7 +89,17 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
// Generous on purpose: the caller is the mail server, one address delivering
|
||||
// everything, and a personal instance gets a handful of forwarded mails a
|
||||
// day. Sixty a minute never touches real delivery and still puts a ceiling
|
||||
// on the one public write path that has none.
|
||||
mailLimiter: newLimiter(60, time.Minute),
|
||||
hookLimiter: newLimiter(60, time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
@@ -93,7 +112,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
// Not middleware.RealIP: that believes a forwarded header from anyone, which
|
||||
// lets a caller pick the address the rate limiter and audit log key on. This
|
||||
// one reads the header only from a trusted proxy — see realip.go.
|
||||
r.Use(realIP(cfg))
|
||||
r.Use(requestLogger(log))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
@@ -113,7 +135,29 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||
// authenticated by the token in the recipient address.
|
||||
r.Post("/inbound/mail", s.handleInboundMail)
|
||||
//
|
||||
// Rate limited for the same reason the beacon is: it is reachable by anybody
|
||||
// who can route to this instance. It is the weaker case of the two — the
|
||||
// beacon writes a row for a stranger, this one writes nothing without a
|
||||
// 256-bit address it has to already know — but an address is shared with mail
|
||||
// servers and printed on a settings page, and once it is known there was no
|
||||
// ceiling at all on what could be made with it.
|
||||
r.Method(http.MethodPost, "/inbound/mail",
|
||||
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
|
||||
|
||||
// En krog ind i indbakken: en URL, et andet program kan skubbe en linje tekst
|
||||
// til. Uden for /api/v1 og uden for sessionen af samme grund som feedet og
|
||||
// posten ovenfor — den, der kalder, er en genvej på en telefon eller et script,
|
||||
// og kan ikke logge ind, så tokenet i stien er hele legitimationen.
|
||||
//
|
||||
// Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet:
|
||||
// forudindlæsning i en browser, en linkkontrol i en chat og en historik, der
|
||||
// bliver besøgt igen, ville alle lave opgaver, ingen har bedt om.
|
||||
//
|
||||
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||
// for den indgående post, og omvendt.
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
|
||||
// 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
|
||||
@@ -200,11 +244,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Behind requireSession, like the API tokens and for the same
|
||||
// reason: a leaked token must not be able to end the sessions of
|
||||
@@ -214,6 +254,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
|
||||
// Turning the second factor on, and the codes that get you back
|
||||
// in. Sessions only, and setup asks for the password.
|
||||
//
|
||||
// These sat outside this group, which meant the door opened with
|
||||
// a token and closed only with a password: `disable` has always
|
||||
// asked for the password, `setup` asked for nothing. A stolen
|
||||
// token could therefore fetch the secret, load it into its own
|
||||
// authenticator, confirm it, and keep the only copy of the
|
||||
// recovery codes. The owner's next login would stop at a code
|
||||
// they do not have, and they could not turn it off without
|
||||
// logging in first. A theft becomes a tenancy — the same
|
||||
// sentence written next door about passkeys, and the same rule.
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Registering a key, and the list of them. Sessions only, for
|
||||
// the same reason: a leaked token must not be able to add its
|
||||
// own way in — that would turn a theft into a tenancy.
|
||||
@@ -259,6 +316,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/key", s.handlePushKey)
|
||||
r.Post("/subscribe", s.handleSubscribePush)
|
||||
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
||||
// One notification, to this account's own devices, reporting what
|
||||
// each of them did with it. The answer to "der kommer ikke nogen".
|
||||
r.Post("/test", s.handlePushTest)
|
||||
})
|
||||
|
||||
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
||||
@@ -275,6 +335,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/mail-address", s.handleGetMailAddress)
|
||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||
|
||||
r.Get("/hook-url", s.handleGetHookURL)
|
||||
r.Post("/hook-url/rotate", s.handleRotateHookURL)
|
||||
|
||||
// Personal API tokens. Behind requireSession rather than the ambient
|
||||
// auth: a token must not be able to mint or revoke another.
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
@@ -289,6 +352,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
r.Post("/summary", s.handleAISummary)
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||
// er en indstilling, ingen tør slå til.
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||
// det er bygget på.
|
||||
r.Post("/ask", s.handleAIAsk)
|
||||
})
|
||||
|
||||
r.Get("/version", s.handleVersion)
|
||||
@@ -304,6 +384,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/", s.handleCreateNote)
|
||||
r.Post("/import", s.handleImportNotes)
|
||||
r.Get("/linking/{kind}/{targetID}", s.handleNotesLinking)
|
||||
// Sharing a note with a person directly — its own access list, beside
|
||||
// the project path. Owner only; see notes_share_handlers.go.
|
||||
r.Get("/{noteID}/shares", s.handleListNoteShares)
|
||||
r.Post("/{noteID}/shares", s.handleShareNote)
|
||||
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
|
||||
// En invitation til en note er en deling, der venter på en konto.
|
||||
// Den kan tages tilbage indtil den bruges; se handleShareNote.
|
||||
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
@@ -311,6 +399,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
|
||||
// in a notes app — paste a screenshot — do nothing at all.
|
||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||
// Mange på én gang. Efter en import af tolv hundrede noter er det at
|
||||
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
|
||||
r.Post("/bulk", s.handleNoteBulk)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
@@ -350,6 +441,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||
r.Post("/sync", s.handleCalendarSyncNow)
|
||||
r.Get("/events", s.handleCalendarEvents)
|
||||
|
||||
// Abonnementer: en kalender, man kun har en adresse til. Flere pr.
|
||||
// person, hvor Google er én — det er adressen, der er identiteten,
|
||||
// hvor tokenet er det for OAuth.
|
||||
r.Post("/subscriptions", s.handleSubscribeCalendar)
|
||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
@@ -396,11 +493,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/", s.handleGetProject)
|
||||
r.Get("/sections", s.handleListSections)
|
||||
r.Get("/members", s.handleListMembers)
|
||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
r.Get("/activity", s.handleActivity)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireProject(store.RoleEditor))
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -408,8 +509,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateProject)
|
||||
r.Delete("/", s.handleDeleteProject)
|
||||
r.Post("/invites", s.handleInvite)
|
||||
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -431,6 +532,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// Beskeden om, hvad der bliver sendt, og svaret på den. I samme
|
||||
// gruppe som resten: den, der kan slå beaconet fra, er den, der
|
||||
// skal have beskeden, og at spørge alle andre ville være at
|
||||
// stille et spørgsmål, de ikke kan svare på.
|
||||
r.Get("/beacon/notice", s.handleBeaconNotice)
|
||||
r.Post("/beacon/notice", s.handleAckBeaconNotice)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
@@ -492,6 +599,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
r.Post("/snooze", s.handleSnoozeTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -614,6 +722,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Content-Security-Policy", s.csp)
|
||||
// The session lives in a __Host- cookie that only exists over HTTPS, so the
|
||||
// whole security model already assumes TLS. HSTS is what makes that
|
||||
// assumption hold against a network attacker: without it, the very first
|
||||
// navigation can be stripped to http:// and the cookie handed over in the
|
||||
// clear. Gated on the same condition as the cookie's Secure flag, so a
|
||||
// plain-HTTP dev instance is never told to demand a certificate it cannot
|
||||
// present. Two years, subdomains included; no preload directive, because
|
||||
// asking to be hard-coded into browsers is a decision the operator makes
|
||||
// for their domain, not one a self-hosted app makes for them.
|
||||
if s.secureCookies() {
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
}
|
||||
// verdande uses none of the powerful browser features, so the safest policy
|
||||
// is to turn them all off: an injected element cannot ask for the camera,
|
||||
// the microphone or the location if the page itself has already declined
|
||||
// them. Named explicitly rather than with a wildcard because the wildcard
|
||||
// form of this header is not universally understood, and a directive a
|
||||
// browser ignores is not a control.
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
|
||||
"payment=(), usb=(), interest-cohort=()")
|
||||
// Isolate the browsing context: a window this page opened, or one that
|
||||
// opened it, cannot keep a handle across the origin boundary. verdande
|
||||
// never scripts another window, so there is nothing to lose and a whole
|
||||
// class of cross-window attacks to close.
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,23 +16,24 @@ import (
|
||||
)
|
||||
|
||||
type taskJSON struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
DueDate string `json:"due_date,omitempty"`
|
||||
DueDatetime string `json:"due_datetime,omitempty"`
|
||||
DurationMin *int `json:"duration_min,omitempty"`
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
RepeatText string `json:"recurrence_text,omitempty"`
|
||||
AssigneeID string `json:"assignee_id,omitempty"`
|
||||
Labels []string `json:"labels"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
SnoozedUntil string `json:"snoozed_until,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
// What the row can say without being opened. Sent with every task rather than
|
||||
// fetched when one is opened, because the point is that you see it in the list.
|
||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
if t.CompletedAt != nil {
|
||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t.SnoozedUntil != nil {
|
||||
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -241,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
t.Labels = req.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
// Lavet til en anden med det samme. Det er den samme besked som at blive
|
||||
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
|
||||
// ind i "det her er dit nu" giver besked og den anden ikke.
|
||||
s.notifyAssigned(r, t)
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -268,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
|
||||
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
|
||||
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
|
||||
// det hver gang nogen retter en overskrift.
|
||||
wasAssigned := ""
|
||||
if req.AssigneeID != nil {
|
||||
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
|
||||
wasAssigned = before.AssigneeID
|
||||
}
|
||||
}
|
||||
|
||||
u := store.TaskUpdate{
|
||||
Description: req.Description, Priority: req.Priority,
|
||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||
@@ -320,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
||||
s.notifyAssigned(r, t)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -698,3 +720,52 @@ func parseLimit(raw string, def, max int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type snoozeRequest struct {
|
||||
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
||||
// but when the task wants to be seen — its due date and everything else stay put.
|
||||
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req snoozeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
var until *time.Time
|
||||
if strings.TrimSpace(req.Until) != "" {
|
||||
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
||||
if perr != nil {
|
||||
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
||||
return
|
||||
}
|
||||
until = &parsed
|
||||
}
|
||||
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
||||
s.storeError(w, r, "snooze task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
verb := "task.snoozed"
|
||||
if until == nil {
|
||||
verb = "task.unsnoozed"
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -284,6 +285,49 @@ func TestTodayAndUpcoming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The horizon is the list's, and it is asked for per request: seven days is the
|
||||
// default, thirty is what Kommende offers for "when does this month get busy", and
|
||||
// anything past the cap is trimmed rather than refused — a client that asks for a
|
||||
// year gets a month, not a 400.
|
||||
func TestUpcomingHorizon(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "om tyve dage",
|
||||
"due_date": userDate(t, 20),
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
|
||||
count := func(query string) (int, int) {
|
||||
t.Helper()
|
||||
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
|
||||
days, _ := view["days"].([]any)
|
||||
var tasks int
|
||||
for _, d := range days {
|
||||
day, _ := d.(map[string]any)
|
||||
list, _ := day["tasks"].([]any)
|
||||
tasks += len(list)
|
||||
}
|
||||
return len(days), tasks
|
||||
}
|
||||
|
||||
if n, found := count(""); n != 7 || found != 0 {
|
||||
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
|
||||
}
|
||||
if n, found := count("?days=30"); n != 30 || found != 1 {
|
||||
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
|
||||
}
|
||||
if n, _ := count("?days=365"); n != 31 {
|
||||
t.Errorf("days=365 returned %d days, want it capped at 31", n)
|
||||
}
|
||||
if n, _ := count("?days=nonsense"); n != 7 {
|
||||
t.Errorf("an unreadable days returned %d days, want the default 7", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFindsAcrossProjects(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -1777,3 +1821,89 @@ func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
t.Errorf("content = %v", moved["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// A word inside a word, on the task side.
|
||||
//
|
||||
// The notes search grew this first — a note holding `keepInventory` could not be
|
||||
// found by typing "keep inventory" — and a task is the same sentence with a
|
||||
// different table behind it. `SpeedAdmin` is one token, so "speed admin" asks for
|
||||
// two words that are nowhere.
|
||||
//
|
||||
// Having it in one of the two searches would be worse than having it in neither:
|
||||
// "it finds it in my notes but not in my tasks" is the next report.
|
||||
func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "Opret kodeord til Kulturskolen (SpeedAdmin)",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Køb kaffe"})
|
||||
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{"speed admin", 1},
|
||||
{"admin", 1},
|
||||
{"speedadmin", 1},
|
||||
{"kulturskolen", 1},
|
||||
{"vandhane", 0},
|
||||
} {
|
||||
resp, body := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(tc.query), nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%q: status %d", tc.query, resp.StatusCode)
|
||||
}
|
||||
tasks, _ := body["tasks"].([]any)
|
||||
if len(tasks) != tc.want {
|
||||
t.Errorf("%q found %d tasks, want %d", tc.query, len(tasks), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A snoozed task sinks to the bottom of the list and greys until its time; waking
|
||||
// it puts it back. The list order is what the row's dimming rides on.
|
||||
func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
|
||||
aID, _ := a["id"].(string)
|
||||
|
||||
// Snooze Alfa two days out.
|
||||
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
|
||||
t.Error("the response does not carry snoozed_until")
|
||||
}
|
||||
|
||||
// Alfa now sorts after Bravo, though it was created first.
|
||||
order := func() []string {
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
var out []string
|
||||
for _, raw := range list["tasks"].([]any) {
|
||||
out = append(out, raw.(map[string]any)["content"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
got := order()
|
||||
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
|
||||
t.Errorf("snoozed task did not sink: order = %v", got)
|
||||
}
|
||||
|
||||
// Wake it, and it comes back above Bravo.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("wake: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if s, _ := body["snoozed_until"].(string); s != "" {
|
||||
t.Errorf("waking left snoozed_until set: %q", s)
|
||||
}
|
||||
if got := order(); got[0] != "Alfa" {
|
||||
t.Errorf("woken task did not return: order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||