Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1e12417b | ||
|
|
b9028e8f7a | ||
|
|
a71af41d9e | ||
|
|
a8db761a4b | ||
|
|
4ef050ae04 | ||
|
|
95e02b567b | ||
|
|
93470259d2 | ||
|
|
6a5228aed8 | ||
|
|
9155e799b1 | ||
|
|
5764271579 | ||
|
|
8a6329bfc9 | ||
|
|
fb8abcaa74 | ||
|
|
956e57f234 | ||
|
|
661e47ebdf | ||
|
|
6178cbbdd8 | ||
|
|
43bd405e36 | ||
|
|
b73f55ad0b | ||
|
|
475ecbe7ef | ||
|
|
159029675c | ||
|
|
b18d9a26e6 | ||
|
|
3768cf946e | ||
|
|
60795126a5 | ||
|
|
664614b924 | ||
|
|
807aa83e58 | ||
|
|
9199709aa5 | ||
|
|
1d15cb18c0 | ||
|
|
ff988d26bb | ||
|
|
b451fd585d | ||
|
|
8c3aa33f13 | ||
|
|
c6c7979d1a | ||
|
|
6154e6b247 | ||
|
|
7e3d300183 | ||
|
|
3cee9bfb6e | ||
|
|
9b067f7057 | ||
|
|
0c45080dc2 | ||
|
|
bba2dab209 | ||
|
|
438095e252 | ||
|
|
3132bba7e5 | ||
|
|
44b28f1cd9 | ||
|
|
b497b76be4 | ||
|
|
dba99b7ecc | ||
|
|
f34690f41a | ||
|
|
1bb2c9cda6 | ||
|
|
cac6c14a23 | ||
|
|
f632fb8643 | ||
|
|
eabb00ecc2 | ||
|
|
d2b5821e57 | ||
|
|
4874f7bbd8 | ||
|
|
8bf4f8fbe4 | ||
|
|
84af63316d | ||
|
|
930522ecf6 | ||
|
|
9ed6f49589 | ||
|
|
de673260a3 | ||
|
|
804c81f74e | ||
|
|
87054ac8ca | ||
|
|
c62dcc3d7c | ||
|
|
0110a86858 | ||
|
|
c868be8f89 | ||
|
|
145fefea02 | ||
|
|
5d911d40fe | ||
|
|
9788bbaaf8 | ||
|
|
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 |
@@ -97,7 +97,13 @@ jobs:
|
||||
# -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 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
@@ -271,3 +277,17 @@ jobs:
|
||||
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 .
|
||||
# Begge udgaver, fordi EDITION rører den sidste stage: en Dockerfile, der
|
||||
# ikke længere bygger med den, skal fejle her og ikke på et mærkat.
|
||||
docker buildx build --platform linux/amd64 --build-arg EDITION=notes --output type=cacheonly .
|
||||
|
||||
# Hvad der IKKE er her, og hvorfor, så den næste læser ikke tror det er glemt:
|
||||
# GitHubs ci.yml har et trin, der henter begge images ind i daemonen og
|
||||
# påstår, at de deler alle lag — altså at verdande og urd bærer samme binær.
|
||||
# Det kræver `--load` to gange, og det er et halvt minut per image på en
|
||||
# runner, der deles med Yggdrasils produktionscontainere og allerede har
|
||||
# dræbt en kørsel på hukommelse (exit 137). Påstanden er den samme kode
|
||||
# begge steder, så det er nok at evaluere den ét sted — og GitHub er
|
||||
# autoritativ for de offentlige projekter.
|
||||
#
|
||||
# Det, der SKAL være her, er ovenstående: at begge varianter kan bygges.
|
||||
|
||||
@@ -40,11 +40,51 @@ jobs:
|
||||
# -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 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
# The iOS app's shell: the interface built the way the app carries it, and
|
||||
# copied into the Xcode project. No Xcode here — that build happens on a Mac —
|
||||
# but a config the CLI cannot read, a webDir that moved, or an app build that
|
||||
# stopped compiling all fail at this step rather than on the day somebody sits
|
||||
# down to ship.
|
||||
app:
|
||||
name: iOS app shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
(cd web && npm ci)
|
||||
(cd app && npm ci)
|
||||
|
||||
- name: Build and sync
|
||||
working-directory: app
|
||||
run: npm run sync
|
||||
|
||||
# The app build must not register the service worker — WKWebView has none
|
||||
# for the app's own scheme. The browser build registers it from index.html.
|
||||
- name: No service worker in the app build
|
||||
run: |
|
||||
if grep -qF 'serviceWorker.register' web/build/index.html; then
|
||||
echo "The app build registers a service worker in index.html."
|
||||
exit 1
|
||||
fi
|
||||
test -f app/ios/App/App/public/index.html
|
||||
echo "App build in the Xcode project: $(find app/ios/App/App/public -type f | wc -l) files."
|
||||
|
||||
spec:
|
||||
name: OpenAPI is valid
|
||||
runs-on: ubuntu-latest
|
||||
@@ -117,9 +157,59 @@ jobs:
|
||||
# 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.
|
||||
#
|
||||
# Loaded into the local daemon rather than discarded, because the step below
|
||||
# has to inspect what came out.
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: verdande:ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: urd:ci
|
||||
build-args: EDITION=notes
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# The claim this repository makes about urd is that it is the same program,
|
||||
# not a fork — and that claim is checkable rather than a sentence in a README.
|
||||
#
|
||||
# Stages 1 and 2 are identical for both builds, and the edition is set with
|
||||
# ENV, which is image *config* rather than a layer. So the two images must
|
||||
# have the same layer digests and differ only in that one variable. A change
|
||||
# that made them diverge — a second COPY, a conditional RUN, a separate build
|
||||
# of the binary — would quietly turn "one source" into two, and the only
|
||||
# symptom would be a bug fixed in one product and not the other.
|
||||
#
|
||||
# This was impossible until the frontend build became deterministic: SvelteKit
|
||||
# put Date.now() in version.json, so no two builds ever matched. See the note
|
||||
# in web/svelte.config.js.
|
||||
- name: The two images are one build
|
||||
run: |
|
||||
set -eu
|
||||
layers() { docker image inspect "$1" --format '{{json .RootFS.Layers}}'; }
|
||||
if [ "$(layers verdande:ci)" != "$(layers urd:ci)" ]; then
|
||||
echo "The verdande and urd images do not share their layers:"
|
||||
layers verdande:ci
|
||||
layers urd:ci
|
||||
echo "They are built from one source, so this is either a Dockerfile that"
|
||||
echo "now builds the binary twice, or a stage that is no longer shared."
|
||||
exit 1
|
||||
fi
|
||||
echo "Shared layers: $(layers verdande:ci | tr ',' '\n' | wc -l)"
|
||||
|
||||
# And the half that stops the check above from passing over two identical
|
||||
# images: they must actually differ in the one way they are supposed to.
|
||||
edition() { docker image inspect "$1" --format '{{json .Config.Env}}' | tr ',' '\n' | grep VERDANDE_EDITION; }
|
||||
echo "verdande: $(edition verdande:ci)"
|
||||
echo "urd: $(edition urd:ci)"
|
||||
edition verdande:ci | grep -q 'VERDANDE_EDITION=full'
|
||||
edition urd:ci | grep -q 'VERDANDE_EDITION=notes'
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
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.
|
||||
# Built on every push, and published from main.
|
||||
#
|
||||
# The publishing used to wait for a manual dispatch, with a note saying it would
|
||||
# run "once Pages is enabled". Pages was enabled — and the condition was never
|
||||
# changed, so the site was built forty times and published none of them. What it
|
||||
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
|
||||
# all answered 404, months after they were written. Nothing failed, because
|
||||
# building *is* what the job was asked to do.
|
||||
#
|
||||
# The lesson is not about this line. It is that "publish later" needs something
|
||||
# that fails when later arrives — a switch that is off and silent looks exactly
|
||||
# like a switch that is on.
|
||||
#
|
||||
# Pull requests still build and never publish: `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`.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -10,6 +23,11 @@ on:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -44,9 +62,9 @@ jobs:
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Only runs once Pages is enabled. Until then the build above still guards
|
||||
# against a broken docs tree.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# From main, and by hand. Never from a pull request — a fork could otherwise
|
||||
# publish over the site by opening one.
|
||||
if: github.event_name != 'pull_request'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,11 @@ node_modules.nosync/
|
||||
# End-to-end runs: the throwaway instance the suite starts, the saved session it
|
||||
# signs in with, and whatever a failure left behind.
|
||||
/web/.playwright-data/
|
||||
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
|
||||
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
|
||||
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
|
||||
# af det samme.
|
||||
/web/.playwright-data-notes/
|
||||
/web/e2e/.auth/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
|
||||
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
WORKDIR /src
|
||||
COPY web/ ./
|
||||
|
||||
# Passed into the frontend build, because SvelteKit's default build version is
|
||||
# `Date.now()` — a millisecond that landed in version.json, in index.html and in
|
||||
# the service worker, changed the content hash of every chunk referencing them, and
|
||||
# so changed those chunks' filenames. Every build of one commit produced different
|
||||
# bytes. See the long note in web/svelte.config.js.
|
||||
ARG VERSION=dev
|
||||
ENV VERDANDE_VERSION=${VERSION}
|
||||
|
||||
# The frontend is built when it is present. During backend-only development it is
|
||||
# not, and the build must still produce a working image rather than failing on a
|
||||
# missing directory — so this stage falls back to a placeholder page. The Go stage
|
||||
@@ -58,6 +66,12 @@ ARG TARGETARCH=amd64
|
||||
# CGO off is what makes this binary static, and it is only possible because the
|
||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||
#
|
||||
# "Reproducible" was false for a year and nobody read the sentence that said it:
|
||||
# the flags above do their job, and the frontend embedded below carried a
|
||||
# millisecond timestamp, so every build of one commit produced a different binary.
|
||||
# Fixed in web/svelte.config.js, and CI now asserts that the two images' layers
|
||||
# match rather than trusting this comment.
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-tags embedweb \
|
||||
-trimpath \
|
||||
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-o /verdande ./cmd/verdande
|
||||
|
||||
# --- 3. what actually ships --------------------------------------------------
|
||||
#
|
||||
# Two images come out of this stage, and the only difference between them is one
|
||||
# environment variable:
|
||||
#
|
||||
# docker build . → verdande
|
||||
# docker build --build-arg EDITION=notes → urd
|
||||
#
|
||||
# Stages 1 and 2 are byte-identical for both, so the binary in the two images is
|
||||
# the same build rather than a rebuild that happens to match. That is the point:
|
||||
# urd is not a fork, and "built from the same source" is a property of the layers
|
||||
# instead of a sentence in a README. ENV is image *config* rather than a layer, so
|
||||
# the two images share every layer digest and differ only in that config — which CI
|
||||
# asserts on every pull request.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
# Declared here as well as used here: an ARG is scoped to the stage it appears in,
|
||||
# so one above the FROM above would be invisible down here. `full` by default,
|
||||
# because that is what this program has always been and an unqualified build must
|
||||
# not quietly produce the other product.
|
||||
ARG EDITION=full
|
||||
|
||||
# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
|
||||
# so the container needs a tz database and CA certificates for outbound SMTP.
|
||||
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
|
||||
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
|
||||
# nightly backups.
|
||||
VOLUME ["/data"]
|
||||
ENV VERDANDE_DATA_DIR=/data \
|
||||
VERDANDE_ADDR=:8080
|
||||
VERDANDE_ADDR=:8080 \
|
||||
VERDANDE_EDITION=${EDITION}
|
||||
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
|
||||
@@ -15,18 +15,37 @@
|
||||
Not what has been, and not what is fated to be: the thing you are in the middle of.
|
||||
Which is what a to-do list is.
|
||||
|
||||
## Two editions: verdande and urd
|
||||
|
||||
This repository builds one program with two faces. Start it with
|
||||
`VERDANDE_EDITION=notes` and it is **urd** — notes and nothing else: the task
|
||||
routes are not mounted, the sidebar has no way to them, and the program calls
|
||||
itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
Urðr is Verðandi's sister in Völuspá, and the poem has the Norns cutting marks into
|
||||
wood. Verdande is what is becoming; urd is what has become and was kept.
|
||||
|
||||
Not a fork, because the two share everything that is hard — accounts, sharing,
|
||||
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
||||
twentieth of the Go here. A test walks both routers and fails in either direction,
|
||||
so "the task routes are not there" is measured rather than promised — 63 routes and
|
||||
five of nine MCP tools. See
|
||||
[Two editions](docs/editions.md).
|
||||
|
||||
## Status
|
||||
|
||||
All six phases are built. See [What works today](#what-works-today) for what is
|
||||
tested and what is not.
|
||||
|
||||
Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
||||
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
|
||||
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
|
||||
|
||||
## 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 +55,36 @@ 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.
|
||||
- **Notes are sealed at rest.** Title and body are encrypted in the database under
|
||||
a key in a file beside it — the title too, because a title beside a sealed body
|
||||
leaves the beginning of every note in the open. A copy of the file is not a copy
|
||||
of the notes.
|
||||
- **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,7 +140,7 @@ 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 |
|
||||
@@ -112,6 +150,17 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
|
||||
| `@name` in a note shares it with that person, and tells them | Done, tested |
|
||||
| Share a project with somebody who already has an account, by name | Done, tested |
|
||||
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
|
||||
| Told when a project is shared with you — and able to leave it | Done, tested |
|
||||
| Push from other programs: a webhook address per person | Done, tested |
|
||||
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
|
||||
| Search the settings, from the page or from ⌘K | Done, tested |
|
||||
| Choose which browser a link opens in | Done, tested |
|
||||
| Images in notes: open full size, copy to the clipboard | Done, tested |
|
||||
| Install count: two values, once a day, one click to stop | Done, tested |
|
||||
|
||||
**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,
|
||||
@@ -202,6 +251,7 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"appId": "dk.nolimit.verdande",
|
||||
"appName": "Verdande",
|
||||
"webDir": "../web/build",
|
||||
"ios": {
|
||||
"contentInset": "never",
|
||||
"scheme": "Verdande"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
@@ -0,0 +1,380 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = (
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3151FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
504EC3151FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
504EC3181FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "CapApp-SPM";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||
productName = "CapApp-SPM";
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,44 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(name: "Default Configuration",
|
||||
sessionRole: connectingSceneSession.role)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "[email protected]",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CAPACITOR_DEBUG</key>
|
||||
<string>$(CAPACITOR_DEBUG)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Verdande</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- A self-hosted Verdande on a home network may have no certificate:
|
||||
http://192.168.1.20:8080 or http://verdande.local. This allows plain
|
||||
http to local addresses only; anything on the internet must still be
|
||||
https. -->
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = CAPBridgeViewController()
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,25 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||
let package = Package(
|
||||
name: "CapApp-SPM",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapApp-SPM",
|
||||
targets: ["CapApp-SPM"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CapApp-SPM",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# CapApp-SPM
|
||||
|
||||
This package is used to host SPM dependencies for your Capacitor project
|
||||
|
||||
Do not modify the contents of it or there may be unintended consequences.
|
||||
@@ -0,0 +1 @@
|
||||
public let isCapacitorApp = true
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "verdande-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "The iOS app: the web interface bundled into a native shell.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cd ../web && VERDANDE_TARGET=app npm run build",
|
||||
"sync": "npm run build && cap sync ios",
|
||||
"open": "cap open ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^8.5.3",
|
||||
"@capacitor/ios": "^8.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
// Not the edition's name: this fires before the configuration is read, so
|
||||
// the edition is not known yet — and one of the things it reports is a
|
||||
// VERDANDE_EDITION the program refused.
|
||||
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -37,7 +40,9 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
log := httpapi.NewLogger(cfg.Dev)
|
||||
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
|
||||
// The edition's own name, so an urd instance's log says urd. The full edition
|
||||
// still logs "starting verdande", which is what the release check reads.
|
||||
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
|
||||
|
||||
// The data volume is the only state that matters; create the whole layout up
|
||||
// front so a failure here is a startup error rather than a surprise at the
|
||||
@@ -80,6 +85,16 @@ func run() error {
|
||||
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))
|
||||
|
||||
@@ -94,6 +109,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,77 @@
|
||||
# The install count
|
||||
|
||||
verdande reports that it exists — and so does urd, which is the same program in
|
||||
its [notes edition](editions.md). 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 — and not which of the two editions is running, so the count does not
|
||||
tell the two apart either. 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.
|
||||
@@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||
| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
@@ -36,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
|
||||
Any Go duration: `720h`, `30m`, `45s`.
|
||||
|
||||
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
|
||||
|
||||
## Mail
|
||||
|
||||
| Variable | Default | What it does |
|
||||
@@ -74,7 +78,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 |
|
||||
@@ -127,11 +131,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
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Two editions: verdande and urd
|
||||
|
||||
One program, built once, with two faces.
|
||||
|
||||
| | **verdande** | **urd** |
|
||||
|---|---|---|
|
||||
| What it is | Tasks, projects and notes | Notes |
|
||||
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
||||
| Routes served | 200 | 137 |
|
||||
| Mark | ᚹ | ᚢ |
|
||||
|
||||
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
||||
mounted, the sidebar has no way to them, search answers with notes only, and the
|
||||
program calls itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
## Running the notes edition
|
||||
|
||||
There is one image. The edition is an environment variable on the container:
|
||||
|
||||
```
|
||||
docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
|
||||
ghcr.io/kristianwind/verdande:latest
|
||||
```
|
||||
|
||||
No second image and no rune of its own, deliberately.
|
||||
|
||||
The edition is for an operator who wants an instance that genuinely has no task
|
||||
routes — the routes are absent from the router, not hidden in the interface. It is
|
||||
**not** how urd is normally reached. That is the second door, below.
|
||||
|
||||
Two details that surprise people who look inside:
|
||||
|
||||
- **The binary is called `/verdande`** and the database file is `verdande.db`, in
|
||||
both editions. Not an oversight: it is the same binary, and renaming the file per
|
||||
edition would be a migration hazard in exchange for nothing a user sees.
|
||||
- **The icons for both editions ship in the one image.** The server decides which
|
||||
to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
|
||||
and never reads the manifest, so renaming paths would have left iOS with the
|
||||
wrong mark.
|
||||
|
||||
## The second door: two apps, one instance
|
||||
|
||||
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
|
||||
database, one login, one set of links between notes and tasks — and two things you
|
||||
can install.
|
||||
|
||||
| | verdande | urd |
|
||||
|---|---|---|
|
||||
| entry | `/` | `/urd` |
|
||||
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
|
||||
| manifest `id` | `/` | `/urd` |
|
||||
| tab and mark | verdande, ᚹ | urd, ð |
|
||||
| database, account, login | the same | the same |
|
||||
|
||||
An operating system does not install a URL, it installs a **manifest**, and the
|
||||
identity of an installed app is that manifest's `id`. Two manifests on one origin
|
||||
with different ids are two apps; two URLs sharing one manifest are one app with a
|
||||
bookmark. That is the whole mechanism.
|
||||
|
||||
The shell is served per door rather than patched by JavaScript after the page
|
||||
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
|
||||
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
|
||||
the manifest's icons altogether.
|
||||
|
||||
urd's manifest is built from verdande's rather than written beside it, so the
|
||||
fields the two genuinely share — the colours, the display mode, the language —
|
||||
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
|
||||
`scope`, the two names and the icons. An icon in the built manifest that urd has no
|
||||
file for stops the server rather than being passed through, because the symptom
|
||||
would otherwise be urd's install prompt quietly showing Verdande's mark.
|
||||
|
||||
### The face is a preference, not a gate
|
||||
|
||||
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
|
||||
tab. It is kept strictly apart from the notes edition in the code, and the
|
||||
distinction is the one thing worth reading twice:
|
||||
|
||||
| | what it means | what follows |
|
||||
|---|---|---|
|
||||
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
|
||||
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
|
||||
|
||||
Conflate them and the urd face starts hiding data rather than chrome: a notebook
|
||||
would stop showing its tasks because somebody chose a different entrance. A test
|
||||
asserts the opposite directly — the urd window still gets 200 from
|
||||
`/api/v1/tasks`.
|
||||
|
||||
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
|
||||
two apps be two apps: they share an origin, so they share localStorage, cookies and
|
||||
the service worker. `sessionStorage` is per window, and an installed PWA is its own
|
||||
window. It survives a reload, which it must — after the first navigation the
|
||||
address is `/noter` and nobody passes through `/urd` again.
|
||||
|
||||
!!! note "One thing the two doors cannot have separately"
|
||||
Theme, typeface and text size are shared, because they are in the localStorage
|
||||
the origin shares. That was a deliberate choice: splitting them per face would
|
||||
mean migrating everybody's stored settings for a difference few people want.
|
||||
|
||||
## Moving between the two, measured
|
||||
|
||||
A `/data` directory moves between the editions in **both directions, losslessly**.
|
||||
Same binary, same schema, same migrations — so this is not a feature that was built,
|
||||
it is a property that was checked:
|
||||
|
||||
| step | result |
|
||||
|---|---|
|
||||
| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
|
||||
| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
|
||||
| the same directory, back to `full` | the task is still there; so is the note |
|
||||
|
||||
The ownership stayed at the panel's service account through all three.
|
||||
|
||||
So the way to move notes is to change the variable. `GET /export/notes.zip` is for
|
||||
getting notes **into other programs** — it writes one `.md` per note and carries the
|
||||
body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
|
||||
is the right division of labour, because the database already holds those.
|
||||
|
||||
!!! note "The task rows stay"
|
||||
An instance run as `notes` still has its tasks in the database; they are simply
|
||||
not served. Harmless on a fresh instance, dead weight on a converted one — and
|
||||
worth knowing before handing a converted instance to somebody else.
|
||||
|
||||
## Why one codebase and not a fork
|
||||
|
||||
The two share everything that is hard: accounts, sessions, passkeys, sharing,
|
||||
invitations, the realtime socket, search, the backup format, the migrations. Notes
|
||||
are about a twentieth of the Go in this repository and reference no task type;
|
||||
tasks reference no note type. A fork would have duplicated the other nineteen
|
||||
twentieths in order to separate the one — and then every security fix would have
|
||||
needed applying twice, by hand, forever.
|
||||
|
||||
So the split is a configuration value read in one place, and the thing that keeps
|
||||
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
||||
walks both routers, compares them against a written-down list of the 63 withheld
|
||||
routes, and fails in **both** directions — a route that appears in the notes
|
||||
edition and a route that stops being withheld are both a failure.
|
||||
|
||||
## An unknown value refuses to start
|
||||
|
||||
`VERDANDE_EDITION=note`, `Notes` and `none` are all rejected at startup, by name,
|
||||
rather than being treated as "not notes".
|
||||
|
||||
The silent direction is the dangerous one here. An operator who sets up an instance
|
||||
for notes and mistypes the value would otherwise get every task route served on it,
|
||||
with no error anywhere and no way to tell from the outside.
|
||||
|
||||
## What urd does not have
|
||||
|
||||
The 63 withheld routes:
|
||||
|
||||
| | what |
|
||||
|---|---|
|
||||
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
|
||||
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
|
||||
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
|
||||
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
|
||||
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
|
||||
| 2 | the Todoist and CSV imports, which create tasks |
|
||||
| 4 | project templates — a template is a project plus sections plus tasks |
|
||||
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
|
||||
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
|
||||
| 1 | the inbox hook, which pushes a line of text in as a task |
|
||||
|
||||
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
|
||||
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
|
||||
shorter. A tool a model can call is a surface, and one that answers "there are no
|
||||
tasks" is worse than one that does not exist: the model tries it, gets an empty
|
||||
answer, and tells the person there is nothing to do.
|
||||
|
||||
Everything else is the same program:
|
||||
|
||||
- accounts, invitations, passkeys and two-factor
|
||||
- projects — which in urd are what a note belongs to
|
||||
- per-note sharing with other people, independent of projects
|
||||
- search, over notes
|
||||
- export, of notes
|
||||
- the mail, calendar and MCP surfaces, minus their task routes and task tools
|
||||
- AI settings, `ask`, and the note actions
|
||||
|
||||
!!! warning "Still being decided"
|
||||
Four connectors are reachable in urd today and have not been settled either
|
||||
way, because each has a plausible notes reading and withholding them would be
|
||||
deciding what this product is rather than closing a hole: the mail-in address,
|
||||
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
|
||||
receive as **tasks**, so in urd they connect and then have nowhere to put
|
||||
anything. The same applies to one handler rather than a route:
|
||||
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
|
||||
|
||||
## The name
|
||||
|
||||
Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them
|
||||
cutting marks into wood — the act of writing something down. Verdande is what is
|
||||
becoming; Urd is what has become and was kept.
|
||||
|
||||
An instance started without the task routes is a different product to the person
|
||||
using it, so it says a different name. That name is read from the configuration in
|
||||
one place (`Config.ProductName`), which is why there is no build-time flag and no
|
||||
second frontend: the frontend is byte-identical in both editions, and the server
|
||||
renames the two files a browser reads before any JavaScript runs — the HTML shell's
|
||||
title and the web manifest.
|
||||
|
||||
!!! note "The beacon counts them separately"
|
||||
Both editions report the same two values once a day — instance id and version
|
||||
— and nothing else; see [The install count](beacon.md). The edition is not in
|
||||
the payload, so the count does not distinguish them.
|
||||
@@ -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,88 @@
|
||||
# The iOS app
|
||||
|
||||
The iOS app is the web interface bundled into a native shell with
|
||||
[Capacitor](https://capacitorjs.com). It is not a second interface: the same
|
||||
Svelte build runs in both, and everything that differs between a browser and the
|
||||
app lives in `web/src/lib/platform.js`.
|
||||
|
||||
It talks to **any** Verdande server. The first screen asks for the address, checks
|
||||
it with `/api/v1/ping`, and then signs in there.
|
||||
|
||||
## How the app differs from a browser
|
||||
|
||||
| | Browser | App |
|
||||
|---|---|---|
|
||||
| Where the interface comes from | the server | inside the app, `capacitor://localhost` |
|
||||
| Which server | the one it was loaded from | the one its owner typed in |
|
||||
| Session | httpOnly cookie | a session token, sent as `Authorization: Bearer` |
|
||||
| Live sync | WebSocket with the cookie | WebSocket opened with a one-use ticket (`POST /ws-ticket`) |
|
||||
| Pictures and downloads | plain `<img>` and links | fetched with the token and shown from a blob (`nativemedia.js`) |
|
||||
| Service worker, Web Push | yes | no — WKWebView has neither |
|
||||
| Passkeys | yes | not yet — a passkey is bound to the server's domain |
|
||||
| Creating the first account | yes | no — done in a browser, then sign in from the app |
|
||||
|
||||
The server side of this is `client: "app"` on `/auth/login`, the
|
||||
`capacitor://localhost` CORS rule, and the ticket. All three are in
|
||||
`docs/openapi.yaml`.
|
||||
|
||||
An app session slides: every use moves its end forward, up to
|
||||
`VERDANDE_APP_SESSION_TTL` (180 days) unused. It is listed under *Enheder* in
|
||||
settings with the name the app gave itself, and can be signed out from there.
|
||||
|
||||
## Building
|
||||
|
||||
Everything up to Xcode runs anywhere, and runs in CI (`iOS app shell`):
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm ci
|
||||
npm run sync # builds web with VERDANDE_TARGET=app, copies it into ios/
|
||||
```
|
||||
|
||||
On a Mac with Xcode:
|
||||
|
||||
```sh
|
||||
npm run open # opens ios/App/App.xcodeproj
|
||||
```
|
||||
|
||||
Choose a simulator and press Run. In the app, type the address of a Verdande —
|
||||
`http://localhost:8080` reaches a dev server on the same Mac from the simulator.
|
||||
|
||||
Plain `http` is allowed to **local** addresses only (`NSAllowsLocalNetworking` in
|
||||
`Info.plist`), so a home server without a certificate works on the same network;
|
||||
anything on the internet has to be `https`.
|
||||
|
||||
`app/ios/` is committed. The web build inside it (`App/App/public`) and the
|
||||
generated config are not — `npm run sync` puts them there.
|
||||
|
||||
The icon and launch image come from `web/static/icon.svg`:
|
||||
|
||||
```sh
|
||||
cd web && node scripts/generate-ios-assets.js
|
||||
```
|
||||
|
||||
## Putting it on a phone
|
||||
|
||||
These steps are the account owner's — they involve Apple's terms, a payment and
|
||||
signing keys.
|
||||
|
||||
1. **Apple Developer Program** membership (99 USD a year) on the Apple ID that
|
||||
should own the app.
|
||||
2. In Xcode → *Signing & Capabilities*: choose that team. The bundle id is
|
||||
`dk.nolimit.verdande`; it can change until the first upload and never after.
|
||||
3. *Product → Archive*, then *Distribute App → TestFlight*.
|
||||
4. In App Store Connect, add testers to the TestFlight build.
|
||||
|
||||
## Not yet
|
||||
|
||||
- **Notifications.** Web Push does not exist in the app. Notifications on an
|
||||
iPhone go through Apple's APNs, and the key that sends them is bound to the
|
||||
app's bundle id — so it cannot live on every self-hosted server. That means a
|
||||
small relay run by the app's publisher, which every instance with an app user
|
||||
would talk to. That is a new connection from other people's servers, and it
|
||||
waits on a decision before it is built.
|
||||
- **The session token in the Keychain.** It is in the WebView's own storage for
|
||||
now, behind one object in `platform.js` so the Keychain plugin can replace it
|
||||
without anything else changing.
|
||||
- **Passkeys** in the app.
|
||||
- **Share sheet** (share text or a link *into* Verdande from another app).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -41,6 +41,16 @@ 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.
|
||||
@@ -79,6 +89,37 @@ 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
|
||||
@@ -97,6 +138,14 @@ Search finds a word wherever it is:
|
||||
body. A word in the title is what the note is about; the same word once in a long
|
||||
body is a mention.
|
||||
|
||||
Two places to search from, and they now agree. The field above the list filters it
|
||||
in place. **⌘K** searches everything at once — tasks, projects, notes and the
|
||||
settings — and picking a note from it opens that note *with the rest of the matches
|
||||
still in the list beside it*. It used to drop you on the ordinary list, so the
|
||||
other results existed only in the popup you had just closed. The address carries
|
||||
the search, which also means a search worth keeping can be bookmarked or sent to
|
||||
somebody.
|
||||
|
||||
## Putting notes away
|
||||
|
||||
Two different things, and the difference matters:
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 244 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: 229 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 220 KiB |
@@ -1,7 +1,8 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -395,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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boombuler/barcode/qr"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
@@ -50,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 {
|
||||
|
||||
@@ -24,8 +24,12 @@ type Config struct {
|
||||
DataDir string
|
||||
|
||||
SessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
// AppSessionTTL is how long a session from the iOS app may sit unused before
|
||||
// it ends. It slides: every use moves the end forward, so a phone in daily use
|
||||
// stays signed in and one left in a drawer does not.
|
||||
AppSessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
|
||||
SMTP SMTP
|
||||
|
||||
@@ -38,11 +42,39 @@ type Config struct {
|
||||
// Empty means a key file beside the data — see internal/secret.
|
||||
SecretKey string
|
||||
|
||||
// Edition decides which half of the program this instance serves.
|
||||
//
|
||||
// "full" is tasks, projects and notes together — what Verdande has always
|
||||
// been. "notes" mounts the notes half and the base underneath it, and leaves
|
||||
// the task routes unmounted: not hidden in the interface, absent from the
|
||||
// router, so an instance that is not a task manager cannot be asked to be one
|
||||
// through its API.
|
||||
//
|
||||
// Notes already stand nearly alone — they reference no task type anywhere in
|
||||
// the store, and the one link between the halves is a `note_links` row whose
|
||||
// kind happens to be "task". What is NOT separable is everything underneath
|
||||
// both: accounts, sessions, invites, sharing, notifications, attachments,
|
||||
// search. Hence one program with two faces rather than two programs: the
|
||||
// alternative is two copies of the base, and two copies of a thing is how the
|
||||
// copies start to disagree.
|
||||
//
|
||||
// Projects stay mounted in both. In the notes edition a project is a
|
||||
// notebook — the same column, the same sharing, a different word — because a
|
||||
// second grouping concept beside `note_shares` would be a second thing to keep
|
||||
// in step for a difference nobody can see.
|
||||
Edition string
|
||||
|
||||
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
||||
// for: a self-hosted app that reaches out without being told to has broken
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
|
||||
// which is always included. Set by the end-to-end suite, which serves the
|
||||
// interface from a second host name to test it the way the app runs it. An
|
||||
// operator has no reason to set it.
|
||||
AppOrigins []string
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
@@ -136,13 +168,22 @@ func Load() (*Config, error) {
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
|
||||
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
|
||||
c.AppOrigins = append(c.AppOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.AppSessionTTL, err = envDuration("VERDANDE_APP_SESSION_TTL", 180*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,6 +212,19 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
|
||||
// Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde
|
||||
// tilbage til den fulde udgave og servere opgaveruterne på en instans, der
|
||||
// blev sat op for ikke at have dem. Det er den stille retning af fejlen, og
|
||||
// den eneste måde at opdage den på ville være at bemærke, at noget virker,
|
||||
// som ikke burde.
|
||||
switch c.Edition {
|
||||
case EditionFull, EditionNotes:
|
||||
case "":
|
||||
c.Edition = EditionFull
|
||||
default:
|
||||
return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes)
|
||||
}
|
||||
|
||||
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
// The two faces of the program. Strings rather than a bool, because a bool named
|
||||
// `NotesOnly` answers one question and this one will be asked again — and a
|
||||
// third edition would mean changing every reading of it rather than adding a case.
|
||||
const (
|
||||
EditionFull = "full"
|
||||
EditionNotes = "notes"
|
||||
)
|
||||
|
||||
// NotesOnly is the one question the router asks. Written as a method so the
|
||||
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
|
||||
// call sites is twenty chances to typo a string the compiler does not check.
|
||||
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
|
||||
|
||||
// ProductName and ProductSlug are the two forms of the name the running instance
|
||||
// calls itself: "Urd" in a sentence, "urd" in a window title, a wordmark and a
|
||||
// manifest.
|
||||
//
|
||||
// Two editions of one program, two names — which is the whole reason the name is
|
||||
// read from the configuration rather than written into the templates. Verdande is
|
||||
// what is happening now; Urd, its sister in Völuspá, is what was cut into the wood
|
||||
// and kept. An instance started without the task routes is a different product to
|
||||
// whoever uses it, and a product that calls itself by its sibling's name is not a
|
||||
// product, it is a flag somebody set.
|
||||
//
|
||||
// The lowercase form is deliberate and not a styling accident: the wordmark, the
|
||||
// repository and the window title are all lowercase, so the slug is the name and
|
||||
// ProductName is the one that gets a capital because it stands in prose.
|
||||
func (c *Config) ProductName() string {
|
||||
if c.NotesOnly() {
|
||||
return "Urd"
|
||||
}
|
||||
return "Verdande"
|
||||
}
|
||||
|
||||
// ProductSlug is ProductName lowercased, kept as its own method rather than as
|
||||
// strings.ToLower at the call sites: the two are the same transformation today and
|
||||
// a name that does not lowercase cleanly would otherwise be a bug in every caller
|
||||
// at once.
|
||||
func (c *Config) ProductSlug() string {
|
||||
if c.NotesOnly() {
|
||||
return "urd"
|
||||
}
|
||||
return "verdande"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A typo in VERDANDE_EDITION must stop the program, not fall through to the full
|
||||
// edition. That is the silent direction of this fault: an instance set up to be a
|
||||
// notes program would serve every task route, and the only way to notice would be
|
||||
// to spot that something works which should not.
|
||||
func TestUnknownEditionIsRefused(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string // the edition we expect, or "" when Load must fail
|
||||
}{
|
||||
{"", EditionFull}, // unset is the full program, as it has always been
|
||||
{EditionFull, EditionFull},
|
||||
{EditionNotes, EditionNotes},
|
||||
{"note", ""}, // a letter short
|
||||
{"Notes", ""}, // the right word, the wrong case
|
||||
{"none", ""}, // plausible and meaning nothing
|
||||
} {
|
||||
t.Setenv("VERDANDE_EDITION", tc.value)
|
||||
t.Setenv("VERDANDE_DATA_DIR", t.TempDir())
|
||||
|
||||
got, err := Load()
|
||||
if tc.want == "" {
|
||||
if err == nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err)
|
||||
continue
|
||||
}
|
||||
if got.Edition != tc.want {
|
||||
t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want)
|
||||
}
|
||||
if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) {
|
||||
t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
preflight := map[string]string{
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type",
|
||||
}
|
||||
|
||||
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("app preflight: status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app preflight: Allow-Origin %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
|
||||
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
|
||||
}
|
||||
|
||||
// An ordinary request from the app can be read.
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app GET: Allow-Origin %q", got)
|
||||
}
|
||||
|
||||
// Anybody else gets nothing, so the browser keeps them from reading.
|
||||
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
|
||||
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The app asks this before anyone has signed in, to tell a Verdande from a
|
||||
// router's login page.
|
||||
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ping: %d", resp.StatusCode)
|
||||
}
|
||||
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
|
||||
t.Errorf("ping = %v", body)
|
||||
}
|
||||
if _, ok := body["version"]; ok {
|
||||
t.Error("ping reveals the version to anybody")
|
||||
}
|
||||
}
|
||||
|
||||
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
|
||||
h := http.Header{}
|
||||
if origin != "" {
|
||||
h.Set("Origin", origin)
|
||||
}
|
||||
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
|
||||
}
|
||||
|
||||
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Without a ticket or a cookie: refused.
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Fatal("a socket opened with no credential")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("no credential: %v", resp)
|
||||
}
|
||||
|
||||
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket, _ := body["ticket"].(string)
|
||||
if resp.StatusCode != http.StatusOK || ticket == "" {
|
||||
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("dial with a ticket: %v", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// And it is the right person's stream: their own new project arrives on it.
|
||||
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(msg), "Live fra telefonen") {
|
||||
t.Errorf("first event was %s", msg)
|
||||
}
|
||||
|
||||
// Spent: the same ticket does not open a second socket.
|
||||
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
|
||||
t.Error("a ticket opened two sockets")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("reused ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATicketExpires(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket := body["ticket"].(string)
|
||||
|
||||
ts.api.wsTickets.mu.Lock()
|
||||
v := ts.api.wsTickets.m[ticket]
|
||||
v.expires = time.Now().Add(-time.Second)
|
||||
ts.api.wsTickets.m[ticket] = v
|
||||
ts.api.wsTickets.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Error("an expired ticket opened a socket")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie path is unchanged: a foreign page cannot open a socket that rides
|
||||
// on the cookie, and the instance's own page can.
|
||||
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dial := func(origin string) error {
|
||||
h := http.Header{}
|
||||
h.Set("Origin", origin)
|
||||
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
|
||||
if err == nil {
|
||||
conn.CloseNow()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := dial("https://evil.example"); err == nil {
|
||||
t.Error("a foreign page opened a socket on the cookie")
|
||||
}
|
||||
if err := dial("capacitor://localhost"); err == nil {
|
||||
t.Error("the app origin opened a socket on a cookie, without a ticket")
|
||||
}
|
||||
if err := dial("http://localhost"); err != nil {
|
||||
t.Errorf("control: the instance's own origin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end suite runs the interface from a second host name and tells the
|
||||
// server to count it as the app's. That origin is then the app's in every
|
||||
// respect, and capacitor://localhost stays one too.
|
||||
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.AppOrigins = []string{"http://app.localhost:8097"}
|
||||
})
|
||||
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Errorf("%s: Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
// Not a prefix match: a neighbour on another port is nobody.
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("a neighbouring origin was answered: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the iOS app needs from a server that a browser gets for free by being on
|
||||
// the same origin. The app loads its interface from capacitor://localhost and
|
||||
// talks to whichever server its owner typed in, so every request it makes is
|
||||
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
|
||||
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
|
||||
// has signed in.
|
||||
|
||||
// appCORS answers the app's origin, and nobody else's.
|
||||
//
|
||||
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
|
||||
// token, never a cookie, so a response to a request with a cookie on it is not
|
||||
// something this header ever needs to unlock. Every other origin gets no CORS
|
||||
// headers at all, exactly as before — the browser then keeps a foreign page from
|
||||
// reading anything, which is the default this must not loosen.
|
||||
func (s *Server) appCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if !s.isAppOrigin(origin) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
// Read by the client: a rate limit's wait, and a download's file name.
|
||||
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
|
||||
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- the live-sync socket without a cookie -----------------------------------
|
||||
|
||||
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
|
||||
// token has no way onto it. Putting the token in the URL would work and would
|
||||
// leave a credential that lasts months in every proxy log the URL passes. A
|
||||
// ticket is the usual answer: asked for with the token, good for one connection,
|
||||
// gone in thirty seconds.
|
||||
|
||||
const wsTicketTTL = 30 * time.Second
|
||||
|
||||
type wsTicket struct {
|
||||
userID string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type wsTickets struct {
|
||||
mu sync.Mutex
|
||||
m map[string]wsTicket
|
||||
}
|
||||
|
||||
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
|
||||
|
||||
func (t *wsTickets) issue(userID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := hex.EncodeToString(raw)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
// Swept on the way in, so the map is bounded by thirty seconds of asking.
|
||||
now := time.Now()
|
||||
for k, v := range t.m {
|
||||
if now.After(v.expires) {
|
||||
delete(t.m, k)
|
||||
}
|
||||
}
|
||||
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// redeem spends a ticket. It is deleted before it is checked, so a second
|
||||
// presentation finds nothing, whether the first one was in time or not.
|
||||
func (t *wsTickets) redeem(ticket string) (string, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
v, ok := t.m[ticket]
|
||||
delete(t.m, ticket)
|
||||
if !ok || time.Now().After(v.expires) {
|
||||
return "", false
|
||||
}
|
||||
return v.userID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
|
||||
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "issue websocket ticket", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ticket": ticket,
|
||||
"expires_in": int(wsTicketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
|
||||
// ordinary session check.
|
||||
//
|
||||
// A socket opened with a ticket skips the origin check, and that is the point
|
||||
// rather than a gap: the origin check exists because a browser attaches the
|
||||
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
|
||||
// be asked for with a credential and put in the URL on purpose — so there is no
|
||||
// ambient authority for a foreign page to borrow.
|
||||
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
// The app never holds a cookie, so a handshake from its origin without a
|
||||
// ticket is not the app. Refused here rather than left to the origin
|
||||
// check, which compares host names only: capacitor://localhost and an
|
||||
// instance served at http://localhost have the same one.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
|
||||
return
|
||||
}
|
||||
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
userID, ok := s.wsTickets.redeem(ticket)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
|
||||
return
|
||||
}
|
||||
user, err := s.db.UserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "load user", err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
|
||||
s.handleWebSocket(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// --- is this a Verdande? -----------------------------------------------------
|
||||
|
||||
// handlePing answers before anybody has signed in, which is when the app needs
|
||||
// it: the person has typed an address and the app has to tell "that is a
|
||||
// Verdande" from "that is a router's login page" before it shows a password
|
||||
// field for it. The edition decides which half of the interface to show.
|
||||
//
|
||||
// No version. /version sits behind a login on purpose, and a public number is a
|
||||
// list of the instances still running an old one.
|
||||
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"product": "verdande",
|
||||
"edition": s.edition(),
|
||||
"api": 1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The iOS app loads its interface from itself — capacitor://localhost — so it
|
||||
// never shares an origin with the server and never gets a cookie back. It signs
|
||||
// in like a browser does and carries the same kind of session, as a bearer token.
|
||||
|
||||
// appDo is a request the way the app makes it: no cookie jar, the token in a
|
||||
// header, and the browser engine reporting the request as cross-site, because to
|
||||
// WKWebView it is.
|
||||
func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, ts.URL+path, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Origin", "capacitor://localhost")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).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
|
||||
}
|
||||
|
||||
func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
"client": "app", "device_name": "Kristians iPhone",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("app login: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
token, _ := body["session_token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("no session_token in %v", body)
|
||||
}
|
||||
if body["user"] == nil {
|
||||
t.Error("no user in the response")
|
||||
}
|
||||
|
||||
// The token is the whole credential, from a foreign origin, reads and writes.
|
||||
resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK || me["email"] != "[email protected]" {
|
||||
t.Fatalf("me with the app token: %d %v", resp.StatusCode, me)
|
||||
}
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// It is a session, not an API token: it reaches what a token may not, and it
|
||||
// is in the list of devices under the name the app gave itself.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
var found bool
|
||||
for _, raw := range body["sessions"].([]any) {
|
||||
s := raw.(map[string]any)
|
||||
if s["current"] == true {
|
||||
found = true
|
||||
if s["device"] != "Kristians iPhone" || s["client"] != "app" {
|
||||
t.Errorf("the app's session reads %v / %v", s["device"], s["client"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the app's own session is not marked current")
|
||||
}
|
||||
|
||||
// And signing out ends it.
|
||||
if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("logout: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("the token still works after logout: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A browser's session never leaves its httpOnly cookie. Its value replayed as a
|
||||
// bearer must not work: that would turn the header into a second way past the
|
||||
// SameSite and CSRF locks the cookie sits behind.
|
||||
func TestABrowserSessionIsNotABearer(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
if cookie == "" {
|
||||
t.Fatal("bootstrap left no session cookie")
|
||||
}
|
||||
if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode)
|
||||
}
|
||||
// Control: the same value is still a perfectly good cookie.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("control: the cookie itself: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginWithASecondFactor(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)
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
token, _ := body["session_token"].(string)
|
||||
if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" {
|
||||
t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["user"] != nil {
|
||||
t.Error("the user came back before the code was given")
|
||||
}
|
||||
|
||||
// Half a login reaches nothing, as in a browser, and does not slide yet.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired {
|
||||
t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
session, _, err := ts.db.SessionByToken(t.Context(), token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute {
|
||||
t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL)
|
||||
}
|
||||
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("finishing an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("me after the code: %d", resp.StatusCode)
|
||||
}
|
||||
session, _, _ = ts.db.SessionByToken(t.Context(), token)
|
||||
if session.IdleTTL != 7*24*time.Hour {
|
||||
t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// A phone in daily use must not sign itself out; one left in a drawer must. The
|
||||
// end moves forward each time the session is used, and a browser's does not.
|
||||
func TestAnAppSessionSlides(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
|
||||
// Pretend it was last used a while ago and is a day from ending.
|
||||
soon := time.Now().Add(24 * time.Hour).Unix()
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("me: %d", resp.StatusCode)
|
||||
}
|
||||
var expires int64
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour {
|
||||
t.Errorf("using the app session left %v on it, want about the app TTL", left)
|
||||
}
|
||||
|
||||
// Control: a browser session used the same way keeps its fixed end.
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if expires != soon {
|
||||
t.Errorf("a browser session's end moved from %d to %d", soon, expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
field string
|
||||
}{
|
||||
{"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"},
|
||||
{"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"},
|
||||
} {
|
||||
tc.body["email"] = "[email protected]"
|
||||
tc.body["password"] = "et langt kodeord"
|
||||
resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil {
|
||||
t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -33,7 +33,14 @@ type userJSON struct {
|
||||
// opposite case and stays in localStorage.
|
||||
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||
NavOrder []string `json:"nav_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// DefaultReminderMin is the account's standing alarm on tasks that have a
|
||||
// clock, as minutes relative to the due moment and negative for before it.
|
||||
// Null is off, which is where every account starts.
|
||||
//
|
||||
// A pointer rather than a number with a sentinel, so that "off" and "at the
|
||||
// due moment" stay different answers: zero is a real setting here.
|
||||
DefaultReminderMin *int `json:"default_reminder_min"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toUserJSON(u *store.User) userJSON {
|
||||
@@ -51,7 +58,8 @@ func toUserJSON(u *store.User) userJSON {
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
DefaultReminderMin: u.DefaultReminderMin,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +120,79 @@ func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetDefaultReminder turns the account's standing alarm on or off.
|
||||
//
|
||||
// `minutes` is relative to the due moment and negative for before it, which is the
|
||||
// convention reminders.offset_min uses because that is the column it lands in. Null
|
||||
// is off. A positive number is refused: an alarm AFTER the thing it is warning
|
||||
// about is not a reminder, and allowing it here would make the one mistake this
|
||||
// value can make — a sign the wrong way round — look like a choice.
|
||||
func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Minutes *int `json:"minutes"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Minutes != nil {
|
||||
switch {
|
||||
case *req.Minutes > 0:
|
||||
writeFieldErrors(w, map[string]string{"minutes": "must be zero or negative — an alarm comes before"})
|
||||
return
|
||||
// A week is the outer edge of "remind me ahead of this"; beyond it the
|
||||
// number is far more likely to be a unit mistake than an intention.
|
||||
case *req.Minutes < -7*24*60:
|
||||
writeFieldErrors(w, map[string]string{"minutes": "must be within a week of the due time"})
|
||||
return
|
||||
}
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetDefaultReminder(r.Context(), user.ID, req.Minutes); err != nil {
|
||||
s.storeError(w, r, "set default reminder", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
// Client is "app" when the iOS app signs in; empty is a browser. DeviceName is
|
||||
// what the app calls itself, shown in the list of signed-in devices.
|
||||
Client string `json:"client"`
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
User *userJSON `json:"user,omitempty"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
// SessionToken is set for an app login only, and instead of the cookie: the
|
||||
// app loads its interface from itself, so it never shares an origin with the
|
||||
// server and a cookie would not come back. It is sent from then on as
|
||||
// `Authorization: Bearer`. A browser never sees it — its session stays in an
|
||||
// httpOnly cookie no script can read.
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
}
|
||||
|
||||
// appClient reports whether a login asked for an app session, and refuses a value
|
||||
// it does not know rather than quietly giving it a browser session: a client that
|
||||
// asked for something else and got a cookie would look signed in until its next
|
||||
// request, and then not be.
|
||||
func appClient(w http.ResponseWriter, client, deviceName string) (app, ok bool) {
|
||||
switch client {
|
||||
case "", "web":
|
||||
return false, true
|
||||
case "app":
|
||||
if strings.TrimSpace(deviceName) == "" {
|
||||
writeFieldErrors(w, map[string]string{"device_name": "an app names the device it signs in from"})
|
||||
return false, false
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
writeFieldErrors(w, map[string]string{"client": `must be "app" or left out`})
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -129,6 +200,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
app, ok := appClient(w, req.Client, req.DeviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
||||
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
|
||||
@@ -173,22 +248,40 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
ttl := s.cfg.SessionTTL
|
||||
if app {
|
||||
ttl = s.cfg.AppSessionTTL
|
||||
}
|
||||
if user.TOTPEnabled {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
token, session, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
resp := loginResponse{TOTPRequired: user.TOTPEnabled}
|
||||
if app {
|
||||
// Sliding only once the login is finished; a pending one keeps its ten
|
||||
// minutes and handleLoginTOTP turns it on.
|
||||
idle := s.cfg.AppSessionTTL
|
||||
if user.TOTPEnabled {
|
||||
idle = 0
|
||||
}
|
||||
if err := s.db.MarkAppSession(r.Context(), session.ID, req.DeviceName, idle); err != nil {
|
||||
s.internal(w, r, "mark app session", err)
|
||||
return
|
||||
}
|
||||
resp.SessionToken = token
|
||||
} else {
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
uj := toUserJSON(user)
|
||||
resp.User = &uj
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type totpRequest struct {
|
||||
@@ -263,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
// An app's session finishes as a sliding one; it already holds its token and
|
||||
// gets no cookie.
|
||||
if session.Client == "app" {
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.AppSessionTTL, s.cfg.AppSessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL, 0); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
@@ -492,7 +597,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
|
||||
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
|
||||
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
|
||||
// instans", så den følger med uden en ekstra tur.
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
Edition string `json:"edition"`
|
||||
}{n == 0, s.edition()})
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
|
||||
@@ -51,12 +51,13 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
cfg := &config.Config{
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
AppSessionTTL: 7 * 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
}
|
||||
if configure != nil {
|
||||
configure(cfg)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return scriptHashesIn(raw)
|
||||
}
|
||||
|
||||
// scriptHashesIn is the same thing over bytes that are already in hand.
|
||||
//
|
||||
// It exists because the notes edition does not serve the index.html that is on
|
||||
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
|
||||
// describe the bytes that actually leave the server. Hashing the file while
|
||||
// serving something else is the shape of mistake that cannot be noticed locally:
|
||||
// the policy is not enforced for a page opened from a file, so the page is blank
|
||||
// in production and nowhere else.
|
||||
func scriptHashesIn(raw []byte) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import "github.com/kristianwind/verdande/internal/config"
|
||||
|
||||
// edition is what this instance tells the world it is.
|
||||
//
|
||||
// Never the empty string, even when the config was built by hand in a test and
|
||||
// never went through config.Load: a flade that reads "" cannot tell "this is the
|
||||
// full program" from "this build forgot to say", and the first of those is the
|
||||
// answer that has always been true.
|
||||
func (s *Server) edition() string {
|
||||
if s.cfg.NotesOnly() {
|
||||
return config.EditionNotes
|
||||
}
|
||||
return config.EditionFull
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The notes edition is a different program, and this is what makes that true.
|
||||
//
|
||||
// A gate in a route tree is the easiest thing in the world to believe and the
|
||||
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
|
||||
// exists, and nothing ever asks whether it covers the routes it was written for.
|
||||
// So this does not check that a gate is *present*. It walks both routers and
|
||||
// compares the two sets of routes they actually serve.
|
||||
//
|
||||
// The difference is written down below. A route added later that differs between
|
||||
// the editions — in either direction — fails here until somebody says which half
|
||||
// it belongs to. That is the point: the list is the decision, and the walk is
|
||||
// what keeps the decision honest.
|
||||
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
full := routesOf(t, config.EditionFull)
|
||||
notes := routesOf(t, config.EditionNotes)
|
||||
|
||||
// Every route the full edition has and the notes edition does not. Written as
|
||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||
// somebody meant, and this has to be a description of what is there.
|
||||
//
|
||||
// And note what this list could NOT catch, because it is the shape of mistake
|
||||
// that outlived the first version of this test. The list went green at 29
|
||||
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
|
||||
// exports, both task imports, the project templates, the sections and six AI
|
||||
// task endpoints were all still served by the notes edition — because the test
|
||||
// compares the router against THIS LIST, and the list was written from the
|
||||
// routes somebody had thought of. It is a drift check, and it cannot be an
|
||||
// is-it-complete check.
|
||||
//
|
||||
// So when a route is added that one edition should not have, the question to
|
||||
// ask is not "does the walk still pass" but "can this endpoint do anything at
|
||||
// all without the half it is being withheld from". Three of the ones above were
|
||||
// settled by reading rather than by the name: a template turned out to create
|
||||
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
|
||||
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
|
||||
withheld := map[string]bool{}
|
||||
for _, r := range []string{
|
||||
"DELETE /api/v1/filters/{filterID}",
|
||||
"DELETE /api/v1/labels/{labelID}",
|
||||
"DELETE /api/v1/reminders/{reminderID}",
|
||||
"DELETE /api/v1/sections/{sectionID}",
|
||||
"DELETE /api/v1/tasks/{taskID}",
|
||||
"DELETE /api/v1/templates/{templateID}",
|
||||
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /.well-known/caldav",
|
||||
"GET /api/v1/ai/plan",
|
||||
"GET /api/v1/delegated",
|
||||
"GET /api/v1/export/projects.zip",
|
||||
"GET /api/v1/export/projects/{projectID}.csv",
|
||||
"GET /api/v1/export/projects/{projectID}.ics",
|
||||
"GET /api/v1/export/tasks.ics",
|
||||
"GET /api/v1/feed",
|
||||
"GET /api/v1/filters",
|
||||
"GET /api/v1/filters/preview",
|
||||
"GET /api/v1/filters/{filterID}/tasks",
|
||||
"GET /api/v1/labels",
|
||||
"GET /api/v1/projects/{projectID}/sections",
|
||||
"GET /api/v1/tasks",
|
||||
"GET /api/v1/tasks/quick-add/preview",
|
||||
"GET /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/tasks/{taskID}/comments",
|
||||
"GET /api/v1/tasks/{taskID}/reminders",
|
||||
"GET /api/v1/templates",
|
||||
"GET /api/v1/upcoming",
|
||||
"GET /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /ics/{token}",
|
||||
"OPTIONS /caldav/*",
|
||||
"PATCH /api/v1/filters/{filterID}",
|
||||
"PATCH /api/v1/labels/{labelID}",
|
||||
"PATCH /api/v1/sections/{sectionID}",
|
||||
"PATCH /api/v1/tasks/{taskID}",
|
||||
"POST /api/v1/ai/inbox/tidy",
|
||||
"POST /api/v1/ai/inbox/tidy/apply",
|
||||
"POST /api/v1/ai/plan/now",
|
||||
"POST /api/v1/ai/tasks/{taskID}/split",
|
||||
"POST /api/v1/feed/rotate",
|
||||
"POST /api/v1/filters",
|
||||
"POST /api/v1/import/csv",
|
||||
"POST /api/v1/import/todoist",
|
||||
"POST /api/v1/labels",
|
||||
"POST /api/v1/projects/{projectID}/sections",
|
||||
"POST /api/v1/projects/{projectID}/sections/reorder",
|
||||
"POST /api/v1/tasks",
|
||||
"POST /api/v1/tasks/quick-add",
|
||||
"POST /api/v1/tasks/{taskID}/attachments",
|
||||
"POST /api/v1/tasks/{taskID}/comments",
|
||||
"POST /api/v1/tasks/{taskID}/complete",
|
||||
"POST /api/v1/tasks/{taskID}/move",
|
||||
"POST /api/v1/tasks/{taskID}/reminders",
|
||||
"POST /api/v1/tasks/{taskID}/reopen",
|
||||
"POST /api/v1/tasks/{taskID}/snooze",
|
||||
"POST /api/v1/templates",
|
||||
"POST /api/v1/templates/{templateID}/use",
|
||||
"POST /inbound/hook/{token}",
|
||||
"PROPFIND /caldav",
|
||||
"PROPFIND /caldav/{userID}",
|
||||
"PROPFIND /caldav/{userID}/{projectID}",
|
||||
"PUT /api/v1/ai/plan",
|
||||
"PUT /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"REPORT /caldav/{userID}/{projectID}",
|
||||
} {
|
||||
withheld[r] = true
|
||||
}
|
||||
|
||||
var unexpected, stillThere []string
|
||||
for r := range full {
|
||||
if notes[r] {
|
||||
if withheld[r] {
|
||||
stillThere = append(stillThere, r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !withheld[r] {
|
||||
unexpected = append(unexpected, r)
|
||||
}
|
||||
}
|
||||
// And the other direction: the notes edition must not grow a route the full
|
||||
// one lacks. A gate that adds is not a gate either.
|
||||
var onlyInNotes []string
|
||||
for r := range notes {
|
||||
if !full[r] {
|
||||
onlyInNotes = append(onlyInNotes, r)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(unexpected)
|
||||
sort.Strings(stillThere)
|
||||
sort.Strings(onlyInNotes)
|
||||
for _, r := range unexpected {
|
||||
t.Errorf("withheld from the notes edition but not written down here: %s", r)
|
||||
}
|
||||
for _, r := range stillThere {
|
||||
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
|
||||
}
|
||||
for _, r := range onlyInNotes {
|
||||
t.Errorf("served only by the notes edition: %s", r)
|
||||
}
|
||||
|
||||
// Printed on the green as well as the red. "The editions differ correctly" over
|
||||
// zero withheld routes and over twenty-nine are the same pass, and only one of
|
||||
// them means anything — so the number is on screen either way.
|
||||
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
|
||||
|
||||
// The half that stops this being a deletion rather than a gate: what the notes
|
||||
// edition is FOR has to still be there.
|
||||
for _, must := range []string{
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"PATCH /api/v1/notes/{noteID}",
|
||||
"DELETE /api/v1/notes/{noteID}",
|
||||
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
|
||||
// enkeltbrugers notesblok, så den halvdel skal navngives her.
|
||||
"POST /api/v1/notes/{noteID}/shares",
|
||||
"GET /api/v1/notes/{noteID}/shares",
|
||||
"POST /api/v1/notes/{noteID}/attachments",
|
||||
"POST /api/v1/notes/import",
|
||||
"GET /api/v1/export/notes.zip",
|
||||
"GET /api/v1/export/account",
|
||||
"POST /api/v1/ai/notes/{noteID}/actions",
|
||||
"GET /api/v1/projects", // a notebook, in this edition
|
||||
"GET /api/v1/search",
|
||||
"GET /api/v1/auth/me",
|
||||
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
|
||||
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
|
||||
// er værktøjslisten bagved, der er kortere.
|
||||
"POST /api/v1/mcp",
|
||||
"POST /mcp",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routesOf builds a server at one edition and returns every route it serves.
|
||||
func routesOf(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
srv := New(&config.Config{
|
||||
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
|
||||
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
out := map[string]bool{}
|
||||
err = chi.Walk(srv.router,
|
||||
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if route != "/" {
|
||||
route = strings.TrimSuffix(route, "/")
|
||||
}
|
||||
out[method+" "+route] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk routes: %v", err)
|
||||
}
|
||||
if len(out) < 50 {
|
||||
// A walk that found almost nothing would make every comparison below pass.
|
||||
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The interface cannot gate itself on something it is not told, and it has to be
|
||||
// told before anybody is signed in — an instance's shape is not a permission.
|
||||
func TestSetupStateSaysWhichEdition(t *testing.T) {
|
||||
for _, tc := range []struct{ edition, want string }{
|
||||
{config.EditionFull, "full"},
|
||||
{config.EditionNotes, "notes"},
|
||||
{"", "full"}, // a config built by hand is the program as it has always been
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||
if got := body["edition"]; got != tc.want {
|
||||
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
// And the field it has always had is still there: adding one must not
|
||||
// quietly replace the answer the sign-in screen depends on.
|
||||
if _, ok := body["needs_setup"]; !ok {
|
||||
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search is mounted in both editions — notes and notebooks have to be findable —
|
||||
// so it is the one door that could still open onto the half the router just shut.
|
||||
//
|
||||
// Not a theoretical case. An instance converted from a full one still has the
|
||||
// tasks in its database, and ⌘K would be the only place they turned up.
|
||||
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
edition string
|
||||
want int
|
||||
}{
|
||||
{config.EditionFull, 1},
|
||||
{config.EditionNotes, 0},
|
||||
} {
|
||||
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
userID, _ := me["id"].(string)
|
||||
if userID == "" {
|
||||
t.Fatalf("no user: %v", me)
|
||||
}
|
||||
|
||||
// Created through the store rather than the API: in the notes edition there
|
||||
// is no route to create one, and the row this is about is the row that is
|
||||
// already there.
|
||||
inbox, err := ts.db.InboxID(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
|
||||
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
|
||||
found, _ := body["tasks"].([]any)
|
||||
if len(found) != tc.want {
|
||||
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
|
||||
}
|
||||
// The full edition's number is the control: a search that finds nothing at
|
||||
// all would satisfy the notes case for the wrong reason.
|
||||
if tc.edition == config.EditionFull && len(found) == 0 {
|
||||
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesEditionOffersNoTaskToolsOverMCP.
|
||||
//
|
||||
// The same hole as the routes, through a different door — and it was still open
|
||||
// after the first gate. An MCP server advertises its tools, so a model connected
|
||||
// to a notes instance would be told it can create_task, call it, get something
|
||||
// back, and tell the person their task was filed.
|
||||
//
|
||||
// Asked through tools/list rather than by reading the server's own slice: that is
|
||||
// what a client actually sees, and the slice is unexported for the same reason.
|
||||
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
|
||||
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
|
||||
noteTools := []string{"search_notes", "create_note", "update_note"}
|
||||
|
||||
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
names := mcpToolNames(t, edition)
|
||||
|
||||
// Both editions: the shared tool, and the notes half.
|
||||
for _, want := range append([]string{"list_projects"}, noteTools...) {
|
||||
if !names[want] {
|
||||
t.Errorf("%s edition does not offer %s", edition, want)
|
||||
}
|
||||
}
|
||||
for _, task := range taskTools {
|
||||
switch edition {
|
||||
case config.EditionFull:
|
||||
if !names[task] {
|
||||
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
|
||||
}
|
||||
default:
|
||||
if names[task] {
|
||||
t.Errorf("the notes edition offers %s over MCP", task)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d tools", edition, len(names))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolNames asks one edition's MCP server for its tool list.
|
||||
func mcpToolNames(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
|
||||
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if resp == nil {
|
||||
t.Fatal("tools/list got no response")
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Tools []struct{ Name string } `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("tools/list: %v — %s", err, raw)
|
||||
}
|
||||
if len(parsed.Result.Tools) == 0 {
|
||||
t.Fatalf("tools/list returned no tools at all: %s", raw)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tool := range parsed.Result.Tools {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -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 == "" {
|
||||
@@ -365,6 +390,9 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// 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
|
||||
@@ -381,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.
|
||||
//
|
||||
@@ -442,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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -272,6 +273,111 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllCSV is every project at once, one CSV per project in a zip.
|
||||
//
|
||||
// A zip rather than one wide CSV with a project column: Todoist's own export is a
|
||||
// file per project, so that is the shape an importer on the other side expects —
|
||||
// and the shape somebody can pick a single project out of without opening a
|
||||
// spreadsheet. Same reasoning as the notes export, which is a folder of Markdown
|
||||
// rather than one long file.
|
||||
//
|
||||
// It exists because the per-project links are per project: fifteen projects meant
|
||||
// fifteen downloads to move your own tasks somewhere else, which is an export that
|
||||
// technically works and nobody uses.
|
||||
func (s *Server) handleExportAllCSV(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.zip"`)
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
// Two projects can share a name — they are still two projects, and a zip with
|
||||
// one entry written twice loses one of them. Same guard as the notes export.
|
||||
used := map[string]int{}
|
||||
for _, project := range projects {
|
||||
exported, err := s.buildExport(r, user, &project)
|
||||
if err != nil {
|
||||
// The response is already streaming, so there is no status left to send.
|
||||
// Stopping leaves a truncated zip, which a zip tool reports as truncated —
|
||||
// better than a complete-looking file with a project missing from it.
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(ics.Filename(project.Name), ".ics")
|
||||
used[name]++
|
||||
if used[name] > 1 {
|
||||
name = fmt.Sprintf("%s (%d)", name, used[name])
|
||||
}
|
||||
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{Name: name + ".csv", Method: zip.Deflate})
|
||||
if err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
if err := todoist.Write(f, todoist.ToRows(exported)); err != nil {
|
||||
s.log.Error("export projects", "err", err, "project", project.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportAllICS is every dated task in one calendar.
|
||||
//
|
||||
// One file and not a zip, unlike the CSV above, because a calendar is made to hold
|
||||
// many things and every reader takes a file with hundreds of VTODOs. The project
|
||||
// travels with each task as its own field, so nothing is lost by merging them.
|
||||
func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, p := range projects {
|
||||
names[p.ID] = p.Name
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
}
|
||||
cal.Tasks = append(cal.Tasks, ics.Task{
|
||||
ID: t.ID, Content: t.Content, Description: t.Description,
|
||||
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
||||
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
||||
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
||||
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-opgaver-`+time.Now().Format("2006-01-02")+`.ics"`)
|
||||
w.Write([]byte(ics.Render(cal)))
|
||||
}
|
||||
|
||||
// buildExport turns a stored project back into the neutral shape the CSV writer
|
||||
// takes, rebuilding the task tree from parent ids.
|
||||
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||