Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e8d609316 | ||
|
|
fcd2443007 | ||
|
|
27a7b04ff3 | ||
|
|
b6cbc6cbc2 | ||
|
|
223a1df85e | ||
|
|
c85bdd5076 | ||
|
|
28825dc7cb | ||
|
|
c930e6163e | ||
|
|
20b8c12b4a | ||
|
|
8d5dd1f1c3 | ||
|
|
c8d67dd07d | ||
|
|
23bd784b07 | ||
|
|
264f48ae11 |
@@ -0,0 +1,46 @@
|
|||||||
|
name: Bug
|
||||||
|
# Gitea kalder feltet `about`, hvor GitHub kalder det `description`, og Gitea
|
||||||
|
# *kræver* det: en skabelon uden bliver afvist frem for at blive vist. Derfor
|
||||||
|
# ligger skabelonerne her for sig frem for at være delt med `.github/`.
|
||||||
|
about: Something does not work
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: And what you expected instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: How to reproduce it
|
||||||
|
placeholder: |
|
||||||
|
1. …
|
||||||
|
2. …
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: The image tag, or the commit you built from.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: how
|
||||||
|
attributes:
|
||||||
|
label: How are you running it
|
||||||
|
options:
|
||||||
|
- As a Rune in Yggdrasil Panel
|
||||||
|
- Docker directly
|
||||||
|
- From source
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Anything in the logs
|
||||||
|
description: Please remove tokens and email addresses first.
|
||||||
|
render: text
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security issue
|
||||||
|
url: https://gitea.nolimit.dk/kw/verdande/src/branch/main/SECURITY.md
|
||||||
|
about: Please report vulnerabilities by email, not as a public issue.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Feature
|
||||||
|
about: Suggest something
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
# Absolut adresse, ikke GitHubs `../../blob/main/…`: Gitea lægger en fil
|
||||||
|
# under `/src/branch/<gren>/`, så den relative form peger ingen steder her.
|
||||||
|
value: |
|
||||||
|
Please check the [non-goals](https://gitea.nolimit.dk/kw/verdande/src/branch/main/README.md#non-goals)
|
||||||
|
first — some things are deliberately not being built.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What are you trying to do
|
||||||
|
description: The problem, not the solution. What does the current version make hard?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: What you had in mind
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Gitea-tvillingen til .github/workflows/ci.yml. Den prøver at nå samme resultat
|
||||||
|
# ad en anden vej, og forskellen er hele pointen med filen:
|
||||||
|
#
|
||||||
|
# INGEN `uses:`. Gitea Actions kan læse GitHubs format, men `DEFAULT_ACTIONS_URL`
|
||||||
|
# står ikke i app.ini på gitea.nolimit.dk, og standarden er github.com — så et
|
||||||
|
# `uses: actions/checkout@v4` her ville blive hentet FRA GitHub, hver gang, også
|
||||||
|
# selvom både koden, runneren og registret er vores egne. Et workflow, der bruger
|
||||||
|
# markedspladsen, er derfor ikke uafhængigt af GitHub; det ser bare sådan ud.
|
||||||
|
#
|
||||||
|
# Prisen er, at værktøjskæden skal komme et sted fra. Den kommer fra jobbets
|
||||||
|
# container, hvilket er billigere end det lyder: `container: golang:1.26` er
|
||||||
|
# nøjagtig det, setup-go ellers ville bruge et trin på at installere.
|
||||||
|
#
|
||||||
|
# Alternativerne, hvis nogen senere vil skrive filen om: sæt DEFAULT_ACTIONS_URL
|
||||||
|
# til gitea.com, som spejler de gængse handlinger — men det flytter kun
|
||||||
|
# afhængigheden fra én andens server til en andens, og det er en indstilling for
|
||||||
|
# HELE instansen, altså også for alt andet, der bygger der. Denne fil rører kun
|
||||||
|
# dette repo.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
# Så en kørsel kan udløses uden at skulle opfinde en commit. Det er ikke
|
||||||
|
# bekvemmelighed: uden den er den eneste måde at få CI til at køre igen mod
|
||||||
|
# uændret kode at skubbe noget tomt, og en tom commit i historikken er en
|
||||||
|
# løgn om, at der skete noget.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ingen `concurrency:`-blok. GitHub-udgaven aflyser en kørsel, når en nyere
|
||||||
|
# kommer, og det er stadig det rigtige at gøre — men understøttelsen kom sent til
|
||||||
|
# Gitea Actions, og et felt, runneren ikke kender, er en fejl ved oversættelsen
|
||||||
|
# frem for en ignoreret linje. Den kan sættes på, når instansen er en version,
|
||||||
|
# der kan.
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
go:
|
||||||
|
name: Go
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Containeren ER værktøjskæden. `-race` kræver en C-oversætter, og den er med
|
||||||
|
# i det officielle golang-image — et slankere ville koste et apt-trin.
|
||||||
|
container:
|
||||||
|
image: golang:1.26
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# Håndrullet, fordi actions/checkout ville blive hentet fra GitHub.
|
||||||
|
# Det er de samme fire greb, den handling tager: init, remote, en
|
||||||
|
# fetch af præcis den commit, der udløste kørslen, og et checkout.
|
||||||
|
#
|
||||||
|
# Legitimationen sættes med -c på selve fetch-kommandoen frem for i
|
||||||
|
# remotens adresse. En adresse med token i havner i .git/config, i
|
||||||
|
# `git remote -v` og i enhver fejlbesked, der citerer den; en header
|
||||||
|
# gør ikke.
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||||
|
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||||
|
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||||
|
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||||
|
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||||
|
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||||
|
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||||
|
# ét bit at vide er for dyrt.
|
||||||
|
godkendt=
|
||||||
|
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||||
|
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||||
|
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||||
|
godkendt=$bruger
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$godkendt" ]; then
|
||||||
|
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "checkout: godkendt som $godkendt"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Formatting
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "These files are not gofmt'd:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
# -race is the whole point of running tests in CI for a server with a
|
||||||
|
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||||
|
- name: Test
|
||||||
|
run: go test -race -count=1 ./...
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: CGO_ENABLED=0 go build ./...
|
||||||
|
|
||||||
|
spec:
|
||||||
|
name: OpenAPI is valid
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||||
|
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||||
|
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||||
|
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||||
|
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||||
|
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||||
|
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||||
|
# ét bit at vide er for dyrt.
|
||||||
|
godkendt=
|
||||||
|
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||||
|
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||||
|
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||||
|
godkendt=$bruger
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$godkendt" ]; then
|
||||||
|
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "checkout: godkendt som $godkendt"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
# Structural validity only. That the spec matches the *router* is checked by
|
||||||
|
# a Go test, which is the half that actually goes stale.
|
||||||
|
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: End-to-end
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# node frem for golang her: Playwrights browsere trækker et lag
|
||||||
|
# Debian-pakker, som npm-imaget er vant til at have, mens Go til gengæld er
|
||||||
|
# fire linjer at hælde ind. Den anden vej rundt er den dyre.
|
||||||
|
container:
|
||||||
|
image: node:22
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||||
|
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||||
|
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||||
|
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||||
|
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||||
|
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||||
|
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||||
|
# ét bit at vide er for dyrt.
|
||||||
|
godkendt=
|
||||||
|
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||||
|
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||||
|
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||||
|
godkendt=$bruger
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$godkendt" ]; then
|
||||||
|
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "checkout: godkendt som $godkendt"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Go
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# Versionen læses ud af go.mod frem for at stå her. To steder at rette
|
||||||
|
# er ét for mange, og den slags glemmes lige indtil CI bygger med en
|
||||||
|
# anden Go end udviklerne.
|
||||||
|
version=$(awk '/^go /{print $2; exit}' go.mod)
|
||||||
|
arch=$(dpkg --print-architecture)
|
||||||
|
curl -fsSL "https://go.dev/dl/go${version}.linux-${arch}.tar.gz" | tar -xz -C /usr/local
|
||||||
|
echo /usr/local/go/bin >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# Chromium for the suite, WebKit for one test.
|
||||||
|
#
|
||||||
|
# A smoke test exists to catch a build that does not work at all, and running
|
||||||
|
# the whole thing in three engines finds the same breakage three times for
|
||||||
|
# three times the cost. Focus is the exception, and it took a real report to
|
||||||
|
# learn it: Safari ignores `autofocus` on anything inserted after the page has
|
||||||
|
# loaded, so every inline form in the app opened with no cursor in it. WebKit
|
||||||
|
# here is the same engine Safari is, and `webkit-forms` in the Playwright
|
||||||
|
# config greps a single test out of the suite to run in it.
|
||||||
|
- name: Install browsers
|
||||||
|
working-directory: web
|
||||||
|
run: npx playwright install --with-deps chromium webkit
|
||||||
|
|
||||||
|
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||||
|
# playwright.config.js. There is nothing to build here first.
|
||||||
|
- name: Test
|
||||||
|
working-directory: web
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
|
# Ingen rapport at hente ned bagefter. GitHub-udgaven lægger
|
||||||
|
# web/playwright-report/ op som et artefakt, og det trin ER en handling fra
|
||||||
|
# markedspladsen — der er ikke en `run:`-form af det. Går suiten i stykker
|
||||||
|
# her, står grunden i loggen frem for i en zip. Skal rapporten tilbage, er
|
||||||
|
# det upload-artifact spejlet ind lokalt, ikke en linje mere i denne fil.
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Docker image builds
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Ingen `container:` her: jobbet skal bruge docker-klienten og buildx, og dem
|
||||||
|
# har runnerens eget ubuntu-image. En golang- eller node-container ville have
|
||||||
|
# sproget og mangle værktøjet.
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||||
|
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||||
|
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||||
|
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||||
|
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||||
|
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||||
|
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||||
|
# ét bit at vide er for dyrt.
|
||||||
|
godkendt=
|
||||||
|
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||||
|
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||||
|
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||||
|
godkendt=$bruger
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$godkendt" ]; then
|
||||||
|
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "checkout: godkendt som $godkendt"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
# Build only — publishing happens on a tag, in release.yml. This job exists
|
||||||
|
# so a Dockerfile that no longer builds fails on the pull request rather
|
||||||
|
# than at release time.
|
||||||
|
#
|
||||||
|
# `type=cacheonly` frem for `--load`: resultatet skal ingen steder hen, og
|
||||||
|
# at hente et image ind i daemonen for straks at glemme det er et halvt
|
||||||
|
# minut hver kørsel. Én arkitektur er nok til at fange en Dockerfile, der
|
||||||
|
# ikke bygger; de to rigtige bygges på et mærkat.
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
|
||||||
|
docker buildx build --platform linux/amd64 --output type=cacheonly .
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Samme udløser som GitHub-workflowet: et semver-tag, og ikke andet.
|
||||||
|
#
|
||||||
|
# Gitea Actions læser samme format som GitHub Actions, men det er *ikke* det
|
||||||
|
# samme sted. Filen ligger derfor for sig frem for at være delt: der er intet
|
||||||
|
# `secrets.GITHUB_TOKEN`, registret er et andet, og der er ingen Releases-side at
|
||||||
|
# skrive til. En delt fil ville være fire betingede led, der hver kun gælder ét
|
||||||
|
# af de to steder.
|
||||||
|
#
|
||||||
|
# Og som i ci.yml: ingen `uses:`. Handlinger hentes fra github.com, så længe
|
||||||
|
# DEFAULT_ACTIONS_URL ikke er sat — se den lange forklaring der.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
name: Build and publish image
|
||||||
|
# `ubuntu-latest` er runnerens egen etiket (`ovh-1`, docker-mode). Ingen
|
||||||
|
# `container:`: jobbet skal bruge docker-klienten og buildx, og dem har
|
||||||
|
# runnerens image.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.nolimit.dk
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config --global --add safe.directory '*'
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||||
|
# To former for basic auth, fordi kun en kørende runner kan svare på,
|
||||||
|
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
|
||||||
|
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
|
||||||
|
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
|
||||||
|
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
|
||||||
|
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
|
||||||
|
# ét bit at vide er for dyrt.
|
||||||
|
godkendt=
|
||||||
|
for bruger in x-access-token "$GITHUB_ACTOR"; do
|
||||||
|
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
|
||||||
|
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
|
||||||
|
godkendt=$bruger
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$godkendt" ]; then
|
||||||
|
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "checkout: godkendt som $godkendt"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
# setup-qemu-action i rå form. Uden den bygger buildx kun værtens egen
|
||||||
|
# arkitektur, og arm64 er ikke valgfrit: et hjemmelab er lige så tit en
|
||||||
|
# Raspberry Pi eller en Apple silicon-VM som en x86-kasse.
|
||||||
|
- name: Emulation for the other architecture
|
||||||
|
run: docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64
|
||||||
|
|
||||||
|
- name: Builder
|
||||||
|
run: docker buildx create --use --name release-verdande 2>/dev/null || docker buildx use release-verdande
|
||||||
|
|
||||||
|
# `GITEA_TOKEN` er automatisk til stede i et Actions-job og har lov at
|
||||||
|
# skrive pakker for den, der ejer repoet. Ingen hemmelighed at sætte op.
|
||||||
|
# --password-stdin frem for --password: det sidste ville stå i processens
|
||||||
|
# kommandolinje, som enhver på maskinen kan læse.
|
||||||
|
- name: Log in to the registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: printf '%s' "$TOKEN" | docker login "$REGISTRY" -u "$GITHUB_ACTOR" --password-stdin
|
||||||
|
|
||||||
|
# metadata-action i rå form.
|
||||||
|
#
|
||||||
|
# v1.4.2 udgiver 1.4.2, 1.4, 1 og latest — så en rune, der er bundet til
|
||||||
|
# :1, får rettelser uden nogensinde at krydse et hovedversionsskifte.
|
||||||
|
#
|
||||||
|
# En forudgivelse (v1.4.2-rc1) får KUN sit eget nøjagtige mærkat. At lade
|
||||||
|
# en rc overskrive :latest eller :1 ville sende den ud til alle, der troede
|
||||||
|
# de fulgte det stabile spor — og mærkatmønsteret ovenfor lukker dem ind.
|
||||||
|
- name: Work out the tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
version=${GITHUB_REF_NAME#v}
|
||||||
|
image="$REGISTRY/$GITHUB_REPOSITORY"
|
||||||
|
args="--tag $image:$version"
|
||||||
|
case "$version" in
|
||||||
|
*-*) echo "Forudgivelse: kun $version." ;;
|
||||||
|
*)
|
||||||
|
minor=${version%.*}
|
||||||
|
major=${version%%.*}
|
||||||
|
args="$args --tag $image:$minor --tag $image:$major --tag $image:latest"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "args=$args" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Mærkater: $args"
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--push \
|
||||||
|
${{ steps.tags.outputs.args }} \
|
||||||
|
--build-arg "VERSION=$GITHUB_REF_NAME" \
|
||||||
|
--label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
|
||||||
|
--label "org.opencontainers.image.version=$GITHUB_REF_NAME" \
|
||||||
|
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
|
||||||
|
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
.
|
||||||
|
# Ingen `cache-from: type=gha`. act_runner har en cache-tjeneste, der
|
||||||
|
# svarer på det navn, men den er ikke prøvet her, og en kold bygning,
|
||||||
|
# der tager nogle minutter længere, er billigere end en udgivelse, der
|
||||||
|
# fejler på noget, som ikke har med bygningen at gøre.
|
||||||
|
|
||||||
|
# Ingen Release oprettes.
|
||||||
|
#
|
||||||
|
# GitHub-workflowet laver en Release, fordi opdateringsnotitsen i appen læser
|
||||||
|
# `releases/latest` derfra. Gitea har sit eget Releases-begreb, men notitsen
|
||||||
|
# peger på GitHub — så en release her ville ingen læse. Skal notitsen flyttes
|
||||||
|
# med, er det `internal/update`, der skal have en ny adresse, og det er en
|
||||||
|
# ændring for sig. (Bemærk også, at GitHub-udgavens softprops-handling taler
|
||||||
|
# mod api.github.com og ikke kan genbruges her.)
|
||||||
@@ -67,5 +67,9 @@ node_modules.nosync/
|
|||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
NOTES-PLAN.md
|
NOTES-PLAN.md
|
||||||
|
|
||||||
|
# Samme grund: instruktionen til flytningen navngiver kassen, runneren, registret
|
||||||
|
# og hvordan man kommer til dem. Arbejdspapir, ikke produktdokumentation.
|
||||||
|
GITEA-FLYTNING.md
|
||||||
|
|
||||||
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -104,15 +104,21 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||||
|
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||||
|
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||||
|
| Notes: archive, grouped list, images, source view | Done, tested |
|
||||||
| Update notice for administrators | Done, tested |
|
| Update notice for administrators | Done, tested |
|
||||||
| Personal API tokens | Done, tested |
|
| Personal API tokens | Done, tested |
|
||||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||||
| End-to-end smoke tests (Playwright) | Done |
|
| End-to-end smoke tests (Playwright) | Done |
|
||||||
| Documentation, landing page, licence | Done |
|
| Documentation, landing page, licence | Done |
|
||||||
|
|
||||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
|
||||||
API calls are implemented against their specifications and have only ever been
|
implemented against their specifications and have only ever been exercised there,
|
||||||
exercised there, not against live services. Everything else has an interface: opening a task shows
|
not against live services. Gmail is no longer in that list — it runs in production,
|
||||||
|
including the flagging and de-duplication that took a day to get right. Calendar
|
||||||
|
subscriptions over ICS and webcal are proven; the Google half of the calendar is
|
||||||
|
not. Everything else has an interface: opening a task shows
|
||||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||||
templates.
|
templates.
|
||||||
|
|||||||
+23
-3
@@ -39,8 +39,28 @@ reply within a week.
|
|||||||
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
||||||
page, rather than allowing `unsafe-inline`.
|
page, rather than allowing `unsafe-inline`.
|
||||||
|
|
||||||
|
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
|
||||||
|
only the body: a note's title *is* its first line, so a readable title beside a
|
||||||
|
sealed body would leave the beginning of every note in the clear.
|
||||||
|
|
||||||
|
The two plaintext copies went with it. `notes_fts` indexed the body and the
|
||||||
|
generated `fold` column held a transliterated copy of it — sealing the body while
|
||||||
|
leaving those would have been theatre, since a stolen file could be read straight
|
||||||
|
out of the index. Search opens the text in Go instead, which costs 33–43 ms
|
||||||
|
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
|
||||||
|
- **Task titles, descriptions and comments are still in the clear**, and so is
|
||||||
|
`note_links` — ids derived from a note's body, so a stolen file still shows which
|
||||||
|
projects and tasks a note points at, though not what it says.
|
||||||
|
|
||||||
## What it does not do
|
## What it does not do
|
||||||
|
|
||||||
verdande has no rate limiting beyond the login and password-reset endpoints, and
|
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
|
||||||
no audit log of reads. It is built to be run behind something — a Cloudflare
|
password, the second factor, the installation beacon, and inbound mail. **Nothing
|
||||||
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
|
else is limited** — an authenticated caller can ask as often as they like, and a
|
||||||
|
signed-in account that wanted to be a nuisance can be one.
|
||||||
|
|
||||||
|
There is no audit log of reads. Who *changed* something is recorded per project;
|
||||||
|
who looked at it is not.
|
||||||
|
|
||||||
|
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
|
||||||
|
VPN — by somebody who knows what they exposed.
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ func run() error {
|
|||||||
}
|
}
|
||||||
db.UseSecrets(box)
|
db.UseSecrets(box)
|
||||||
|
|
||||||
|
// Noter, der stadig ligger åbne, lukkes nu.
|
||||||
|
//
|
||||||
|
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
|
||||||
|
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
|
||||||
|
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
|
||||||
|
// så den koster et gennemløb af id'er hver anden gang.
|
||||||
|
//
|
||||||
|
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
|
||||||
|
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
|
||||||
|
// er værre end en, der siger fra.
|
||||||
|
if sealed, err := db.SealNotes(context.Background()); err != nil {
|
||||||
|
return fmt.Errorf("seal notes: %w", err)
|
||||||
|
} else if sealed > 0 {
|
||||||
|
log.Info("notes sealed", "count", sealed)
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Version = version
|
httpapi.Version = version
|
||||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||||
|
|
||||||
|
|||||||
+15
-9
@@ -144,14 +144,20 @@ Apple Notes is.
|
|||||||
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
|
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
|
||||||
uploadable parts, keeping each note with its own pictures.
|
uploadable parts, keeping each note with its own pictures.
|
||||||
|
|
||||||
## What is not encrypted
|
## What is encrypted, and what is not
|
||||||
|
|
||||||
Note bodies are stored as plain text. **A note holding a password or an API key is
|
**A note's title and body are sealed** in the database, under a key kept in a file
|
||||||
holding it in plain text in every backup you can download.** The mailbox
|
beside it rather than inside it. A backup you download does not carry the key, so
|
||||||
credentials, OAuth tokens and AI keys are sealed; note bodies are not, and neither
|
it does not carry your notes.
|
||||||
are task titles, descriptions or comments.
|
|
||||||
|
|
||||||
Encrypting them is on the list and is bigger than it sounds — the full-text index
|
Both halves, not only the body: a note's title is its first line, so a readable
|
||||||
and the folded search copy are both derived from the body, so both would have to go
|
title beside a sealed body would leave the beginning of every note in the open.
|
||||||
and search would have to be rewritten. Until then: keep secrets somewhere built for
|
|
||||||
them.
|
**What is still in the clear:** task titles, descriptions and comments — and the
|
||||||
|
list of what a note links to, so a stolen file shows which projects and tasks a
|
||||||
|
note points at, though not what it says.
|
||||||
|
|
||||||
|
Sealing the notes cost the search its index — a full-text index over a note's body
|
||||||
|
is another copy of that body — so search reads the notes themselves now. At twelve
|
||||||
|
hundred notes that is about forty milliseconds, and the box waits a quarter of a
|
||||||
|
second after you stop typing, so you will not see it.
|
||||||
|
|||||||
+26
-8
@@ -7,14 +7,14 @@ verdande ships with a Rune manifest for
|
|||||||
|
|
||||||
```
|
```
|
||||||
create container: Error response from daemon:
|
create container: Error response from daemon:
|
||||||
No such image: ghcr.io/kristianwind/verdande:latest
|
No such image: gitea.nolimit.dk/kw/verdande:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This message is a liar. It does not mean the tag is missing from the registry —
|
This message is a liar. It does not mean the tag is missing from the registry —
|
||||||
it means the image is not on the host, and the panel's attempt to fetch it failed
|
it means the image is not on the host, and the panel's attempt to fetch it failed
|
||||||
without saying so.
|
without saying so.
|
||||||
|
|
||||||
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
|
The usual cause is a **private** package. Yggdrasil pulls anonymously: its
|
||||||
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
|
||||||
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
|
||||||
looks locally — then reports the image as missing.
|
looks locally — then reports the image as missing.
|
||||||
@@ -24,10 +24,28 @@ looks locally — then reports the image as missing.
|
|||||||
sends them itself with each request. Yggdrasil is a different client and
|
sends them itself with each request. Yggdrasil is a different client and
|
||||||
sends none, so logging in changes nothing for the panel.
|
sends none, so logging in changes nothing for the panel.
|
||||||
|
|
||||||
**Make the package public.** Under
|
**The package has to be readable without credentials**, and the only way to know
|
||||||
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
|
is to ask the registry for a token as a stranger:
|
||||||
→ *Change visibility*. The repository stays private; only the built image becomes
|
|
||||||
readable, which is what the manifest and these pages have always assumed.
|
```bash
|
||||||
|
curl -s "https://ghcr.io/token?scope=repository:kristianwind/verdande:pull&service=ghcr.io"
|
||||||
|
```
|
||||||
|
|
||||||
|
A 200 with a `token` in it means anyone can pull, which means the panel can. A
|
||||||
|
401 means it cannot, whatever the package page says.
|
||||||
|
|
||||||
|
!!! danger "Do not test this with `docker --config /tmp/empty pull`"
|
||||||
|
It looks like the honest test and is not. An empty config directory does not
|
||||||
|
stop the machine's credential helper, so the pull is quietly authenticated
|
||||||
|
with credentials you forgot you had — and reports success for an image no
|
||||||
|
stranger can fetch. This cost a production instance an hour of downtime: the
|
||||||
|
image was declared public on that evidence, the rune was pointed at it, and
|
||||||
|
the host — which had never logged in — could not pull a thing.
|
||||||
|
|
||||||
|
A self-hosted Gitea is where this bites. An instance with `REQUIRE_SIGNIN_VIEW`
|
||||||
|
turned on refuses anonymous tokens for its registry too, and no per-package
|
||||||
|
setting overrides it. Making the source repository public does not change it
|
||||||
|
either; that was tried.
|
||||||
|
|
||||||
Once it is public, press **Start** on the server you already have. There is no
|
Once it is public, press **Start** on the server you already have. There is no
|
||||||
need to delete it and make another: the image reference is read from the rune at
|
need to delete it and make another: the image reference is read from the rune at
|
||||||
@@ -37,8 +55,8 @@ To keep the package private you have to put the image on the host yourself,
|
|||||||
before creating the server, and again after every release:
|
before creating the server, and again after every release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo docker login ghcr.io -u kristianwind
|
sudo docker login gitea.nolimit.dk -u kw
|
||||||
sudo docker pull ghcr.io/kristianwind/verdande:latest
|
sudo docker pull gitea.nolimit.dk/kw/verdande:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ cache, no queue and no second process.
|
|||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
!!! note "If the pull is refused"
|
!!! note "If the pull is refused"
|
||||||
The GHCR package is public, so this needs no credentials. If it is ever set
|
The image is published from a self-hosted Gitea and is readable without
|
||||||
back to private, `docker login ghcr.io` with a token carrying the
|
credentials — the package is not linked to a repository, so its visibility
|
||||||
`read:packages` scope before pulling — and see
|
follows the account rather than the (private) source repo. If it is ever set
|
||||||
|
back to private, `docker login gitea.nolimit.dk` before pulling — and see
|
||||||
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
|
||||||
fails differently and much more confusingly under Yggdrasil.
|
fails differently and much more confusingly under Yggdrasil.
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -47,6 +47,12 @@ type Config struct {
|
|||||||
// ProviderCompatible and useful for a proxy in front of the others.
|
// ProviderCompatible and useful for a proxy in front of the others.
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Model string
|
Model string
|
||||||
|
// AllowPrivate lets BaseURL be on this machine or its private network.
|
||||||
|
//
|
||||||
|
// Set by the caller from who is asking, never read from storage: it is the
|
||||||
|
// answer to "may this person reach the LAN", and that answer can change after
|
||||||
|
// the address was saved. See the handler for who gets it and why.
|
||||||
|
AllowPrivate bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) Configured() bool {
|
func (c Config) Configured() bool {
|
||||||
@@ -73,13 +79,17 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Client {
|
func New(cfg Config) *Client {
|
||||||
|
dial := safedial.Client
|
||||||
|
if cfg.AllowPrivate {
|
||||||
|
dial = safedial.AllowPrivate
|
||||||
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
// Generous: a local model on modest hardware can take a while to produce
|
// Generous: a local model on modest hardware can take a while to produce
|
||||||
// its first token, and the alternative is a timeout that only ever fires
|
// its first token, and the alternative is a timeout that only ever fires
|
||||||
// for the people running their own.
|
// for the people running their own.
|
||||||
// safedial, not a plain client. `base_url` is typed into a field in the
|
// safedial by default. `base_url` is typed into a field in the interface,
|
||||||
// interface, so it is a user-supplied address — and a server that fetches a
|
// so it is a user-supplied address — and a server that fetches a
|
||||||
// user-supplied address is a way to ask it to reach things the caller
|
// user-supplied address is a way to ask it to reach things the caller
|
||||||
// cannot: the panel next door, a database on the same bridge, the cloud
|
// cannot: the panel next door, a database on the same bridge, the cloud
|
||||||
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||||
@@ -87,7 +97,13 @@ func New(cfg Config) *Client {
|
|||||||
// The check is on the resolved address rather than on the URL, because a
|
// The check is on the resolved address rather than on the URL, because a
|
||||||
// name answers whatever its owner says — and can answer differently the
|
// name answers whatever its owner says — and can answer differently the
|
||||||
// second time, after a parse-time check has passed.
|
// second time, after a parse-time check has passed.
|
||||||
http: safedial.Client(120 * time.Second),
|
//
|
||||||
|
// And then the exception, which is the whole reason this provider exists:
|
||||||
|
// a model you run yourself IS on that network. Blocking it made the two
|
||||||
|
// lines above true and the feature useless — the comment about a local
|
||||||
|
// model being slow to its first token was written for a case the dialler
|
||||||
|
// refused to reach. AllowPrivate is granted per request, not per address.
|
||||||
|
http: dial(120 * time.Second),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,20 +215,31 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address is checked where it is typed, as well as when it is dialled.
|
// The address is checked where it is typed, as well as when it is dialled. A
|
||||||
|
// scheme other than http or https is refused outright, for everybody —
|
||||||
|
// file:// and gopher:// are not providers.
|
||||||
//
|
//
|
||||||
// safedial refuses a private destination at connect time and is the actual
|
// The private-network wall has one exception, and it is the point of the
|
||||||
// wall; this is so the person entering it hears about it now rather than
|
// provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model
|
||||||
// discovering it later as a failed summary. A scheme other than http or https
|
// on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150.
|
||||||
// is refused outright — file:// and gopher:// are not providers.
|
// safedial refuses exactly those, so the address the field suggested in its
|
||||||
|
// own placeholder was an address the server would not accept.
|
||||||
|
//
|
||||||
|
// Admins only, because the wall is there for a real reason: a server that
|
||||||
|
// fetches a user-supplied address lets the person supplying it reach what
|
||||||
|
// they cannot reach themselves. An admin on a self-hosted instance already
|
||||||
|
// owns the host and everything on its network, so this grants them nothing
|
||||||
|
// new. An invited user is a different person, and for them it stays shut.
|
||||||
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if reason := safedial.CheckURL(url); reason != "" {
|
if !user.IsAdmin {
|
||||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
if reason := safedial.CheckURL(url); reason != "" {
|
||||||
return
|
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
req.BaseURL = url
|
req.BaseURL = url
|
||||||
}
|
}
|
||||||
@@ -265,11 +276,20 @@ func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
|
|||||||
v, _ := values[key].(string)
|
v, _ := values[key].(string)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
// Worked out per request rather than stored beside the address: it answers
|
||||||
|
// "may this person reach the private network", and somebody who is no longer
|
||||||
|
// an admin should stop being able to. A flag written at save time would
|
||||||
|
// outlive the role that granted it.
|
||||||
|
allowPrivate := false
|
||||||
|
if u := userFrom(r.Context()); u != nil && u.IsAdmin {
|
||||||
|
allowPrivate = true
|
||||||
|
}
|
||||||
return ai.Config{
|
return ai.Config{
|
||||||
Provider: ai.Provider(str("provider")),
|
Provider: ai.Provider(str("provider")),
|
||||||
APIKey: str("api_key"),
|
APIKey: str("api_key"),
|
||||||
BaseURL: str("base_url"),
|
BaseURL: str("base_url"),
|
||||||
Model: str("model"),
|
Model: str("model"),
|
||||||
|
AllowPrivate: allowPrivate,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -719,6 +719,86 @@ func TestTheAIProviderSurvivesConnectingSomethingElse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reported from use as "the AI settings are not saved" — the second time, and a
|
||||||
|
// different cause than the one above.
|
||||||
|
//
|
||||||
|
// The provider is called "OpenAI-kompatibel (Ollama, LM Studio, …)" and the
|
||||||
|
// address field suggests http://localhost:11434/v1. Both of those live on the
|
||||||
|
// private network, which safedial refuses — so the address the interface proposed
|
||||||
|
// was the address the server rejected, and the one provider that exists for
|
||||||
|
// running your own model could not be configured at all.
|
||||||
|
//
|
||||||
|
// It looked like a save that did nothing, because the 422 came back on a field
|
||||||
|
// with nowhere to show it. Two bugs wearing one coat.
|
||||||
|
func TestAdminMayPointAIAtAModelOnTheLAN(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// The three shapes a self-hosted model actually takes: another machine on the
|
||||||
|
// LAN, the loopback name, and the loopback address.
|
||||||
|
for _, addr := range []string{
|
||||||
|
"http://192.168.1.150:8080/v1",
|
||||||
|
"http://localhost:11434/v1",
|
||||||
|
"http://127.0.0.1:1234/v1",
|
||||||
|
} {
|
||||||
|
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||||
|
"provider": "compatible", "model": "gemma-4-26b-qat", "base_url": addr,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("save %s: status %d, body %v", addr, resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/ai/settings", nil)
|
||||||
|
if body["base_url"] != "http://127.0.0.1:1234/v1" {
|
||||||
|
t.Errorf("base_url = %v after saving, want it to have survived", body["base_url"])
|
||||||
|
}
|
||||||
|
if body["provider"] != "compatible" {
|
||||||
|
t.Errorf("provider = %v", body["provider"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wall stays up for everybody else.
|
||||||
|
//
|
||||||
|
// An admin on a self-hosted instance already owns the host and its network, so a
|
||||||
|
// private destination grants them nothing they did not have. An invited user is a
|
||||||
|
// different person, and for them the address field would be a way to find out
|
||||||
|
// what answers on the host's network.
|
||||||
|
func TestAnInvitedUserCannotPointAIAtThePrivateNetwork(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "andreas@example.dk", "Andreas")
|
||||||
|
|
||||||
|
resp, body := other.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||||
|
"provider": "compatible", "model": "llama3.1",
|
||||||
|
"base_url": "http://192.168.1.150:8080/v1",
|
||||||
|
})
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
t.Fatal("an invited user was allowed to point the AI at the private network")
|
||||||
|
}
|
||||||
|
fields, _ := body["fields"].(map[string]any)
|
||||||
|
if fields["base_url"] == nil {
|
||||||
|
t.Errorf("the refusal did not name the field it was about: %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scheme that is not http or https is refused for everybody, admin or not.
|
||||||
|
func TestAISettingsRefuseANonHTTPAddress(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||||||
|
"provider": "compatible", "model": "llama3.1", "base_url": "file:///etc/passwd",
|
||||||
|
})
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
t.Fatal("file:// was accepted as a provider address")
|
||||||
|
}
|
||||||
|
fields, _ := body["fields"].(map[string]any)
|
||||||
|
if fields["base_url"] == nil {
|
||||||
|
t.Errorf("no error on base_url: %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGmailSettings(t *testing.T) {
|
func TestGmailSettings(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
ts.bootstrap(t)
|
ts.bootstrap(t)
|
||||||
|
|||||||
@@ -650,8 +650,18 @@ func pad2(n int) string {
|
|||||||
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
|
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
|
||||||
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
|
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
|
||||||
// all of it and leave the task not repeating at all.
|
// all of it and leave the task not repeating at all.
|
||||||
|
//
|
||||||
|
// Mellemleddet må være et ord og ikke kun et tal. "hver anden tirsdag" er den måde,
|
||||||
|
// folk siger det på — recurrence-pakken har oversat "hver anden" til "hver 2."
|
||||||
|
// siden den blev skrevet — men her stoppede mønsteret efter "anden" og gav den
|
||||||
|
// sætning videre. Den er ikke en regel, så "hver anden" blev stående i titlen og
|
||||||
|
// "tirsdag" blev en forfaldsdato. Sløjfen nedenfor kan kun *fjerne* ord fra
|
||||||
|
// enden; den kan ikke hente et, mønsteret aldrig tog med.
|
||||||
|
//
|
||||||
|
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||||
|
// ikke var det.
|
||||||
var reRepeat = regexp.MustCompile(
|
var reRepeat = regexp.MustCompile(
|
||||||
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+|(?:anden|andet|tredje|fjerde|femte|sjette|other)\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
|
||||||
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
|
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
|
||||||
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
|
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
|
||||||
|
|
||||||
|
|||||||
@@ -603,3 +603,36 @@ func TestASectionIsWrittenWithASlash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "hver anden tirsdag" er den måde, folk siger det på.
|
||||||
|
//
|
||||||
|
// recurrence-pakken har oversat "hver anden" til "hver 2." siden den blev skrevet,
|
||||||
|
// men sætningen nåede aldrig frem til den: mønsteret her tog kun ét ord efter
|
||||||
|
// "hver", så det, der blev givet videre, var "hver anden" — ikke en regel. Så blev
|
||||||
|
// de to ord stående i titlen, og "tirsdag" blev til en forfaldsdato. Opgaven
|
||||||
|
// gentog sig ikke, og der stod ikke noget om det.
|
||||||
|
//
|
||||||
|
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
|
||||||
|
// ikke var det.
|
||||||
|
func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
rule string
|
||||||
|
left string
|
||||||
|
}{
|
||||||
|
{"vande planterne hver anden tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||||
|
{"vande planterne hver 2. tirsdag", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "vande planterne"},
|
||||||
|
{"water the plants every other tuesday", "FREQ=WEEKLY;BYDAY=TU;INTERVAL=2", "water the plants"},
|
||||||
|
{"gå tur hver tredje søndag", "FREQ=WEEKLY;BYDAY=SU;INTERVAL=3", "gå tur"},
|
||||||
|
// Den, der virkede i forvejen, skal blive ved med at gøre det.
|
||||||
|
{"tømme opvasker hver mandag", "FREQ=WEEKLY;BYDAY=MO", "tømme opvasker"},
|
||||||
|
} {
|
||||||
|
got := Parse(tc.text, time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC), "da")
|
||||||
|
if got.Recurrence != tc.rule {
|
||||||
|
t.Errorf("%q gav reglen %q, want %q", tc.text, got.Recurrence, tc.rule)
|
||||||
|
}
|
||||||
|
if got.Content != tc.left {
|
||||||
|
t.Errorf("%q efterlod titlen %q, want %q", tc.text, got.Content, tc.left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,17 +24,58 @@
|
|||||||
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
||||||
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
||||||
// wall around the inside.
|
// wall around the inside.
|
||||||
|
//
|
||||||
|
// With one named exception, for Tailscale — see tailnets below.
|
||||||
package safedial
|
package safedial
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// tailnets are Tailscale's own ranges, and they are not "the inside".
|
||||||
|
//
|
||||||
|
// A tailnet is not a network you happen to be on; it is one every member had to
|
||||||
|
// authenticate to join. A verdande whose host is on one reaches its peers on
|
||||||
|
// purpose, and the operator who joined it meant to.
|
||||||
|
//
|
||||||
|
// Half of this already worked, by luck rather than intent: Go's IsPrivate covers
|
||||||
|
// RFC1918 and not the carrier-grade NAT range Tailscale borrows, so 100.x
|
||||||
|
// addresses passed the wall without anybody deciding they should. The IPv6 half
|
||||||
|
// did not — fd7a:115c:a1e0::/48 sits inside fc00::/7 and was refused. The same
|
||||||
|
// machine was reachable or not depending on which family its address had, which
|
||||||
|
// is the kind of difference nobody debugs on purpose.
|
||||||
|
//
|
||||||
|
// Naming both makes the allowance deliberate, and writes down its cost: on an
|
||||||
|
// instance with people you have invited, this is a network they can now ask the
|
||||||
|
// server to reach. That is the trade, and it is the operator's to make.
|
||||||
|
var tailnets = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
|
netip.MustParsePrefix("fd7a:115c:a1e0::/48"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTailscale reports whether an address belongs to a tailnet.
|
||||||
|
func IsTailscale(ip net.IP) bool {
|
||||||
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Unmap first: ParseIP gives a v4 address as 4-in-6, and a v4 prefix does not
|
||||||
|
// contain a v6-shaped address however equal the two look when printed.
|
||||||
|
addr = addr.Unmap()
|
||||||
|
for _, n := range tailnets {
|
||||||
|
if n.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
||||||
// deliberately does not say which address it resolved to: the answer is itself
|
// deliberately does not say which address it resolved to: the answer is itself
|
||||||
// the thing an attacker is fishing for.
|
// the thing an attacker is fishing for.
|
||||||
@@ -46,8 +87,15 @@ func (e *ErrPrivate) Error() string {
|
|||||||
|
|
||||||
// Blocked reports whether an address must not be dialled.
|
// Blocked reports whether an address must not be dialled.
|
||||||
func Blocked(ip net.IP) bool {
|
func Blocked(ip net.IP) bool {
|
||||||
return ip == nil ||
|
if ip == nil {
|
||||||
ip.IsLoopback() ||
|
return true
|
||||||
|
}
|
||||||
|
// Before the private check, not after: a tailnet's IPv6 range is unique-local,
|
||||||
|
// so asking IsPrivate first would refuse it.
|
||||||
|
if IsTailscale(ip) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ip.IsLoopback() ||
|
||||||
ip.IsUnspecified() ||
|
ip.IsUnspecified() ||
|
||||||
ip.IsLinkLocalUnicast() ||
|
ip.IsLinkLocalUnicast() ||
|
||||||
ip.IsLinkLocalMulticast() ||
|
ip.IsLinkLocalMulticast() ||
|
||||||
|
|||||||
@@ -53,3 +53,47 @@ func TestAllowPrivateStillReaches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tailscale skal virke, og på begge familier.
|
||||||
|
//
|
||||||
|
// IPv4-halvdelen slap allerede igennem, men ved et tilfælde: Gos IsPrivate
|
||||||
|
// dækker RFC1918 og ikke det CGNAT-område, Tailscale låner. IPv6-halvdelen gjorde
|
||||||
|
// ikke — fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså
|
||||||
|
// tilgængelig eller ej alt efter, hvilken adressefamilie man ramte den på.
|
||||||
|
//
|
||||||
|
// Testen står her for at holde begge dele fast: at 100.x er tilladt med vilje og
|
||||||
|
// ikke af held, og at fd7a: ikke ryger tilbage bag muren, næste gang nogen rydder
|
||||||
|
// op i prædikatet.
|
||||||
|
func TestTailnetIsNotTheInside(t *testing.T) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"100.64.0.0", "100.100.100.100", "100.127.255.255",
|
||||||
|
"fd7a:115c:a1e0::1", "fd7a:115c:a1e0:ab12:3456:7890:abcd:ef01",
|
||||||
|
} {
|
||||||
|
if Blocked(net.ParseIP(s)) {
|
||||||
|
t.Errorf("Blocked(%s) = true, men det er en tailnet-adresse", s)
|
||||||
|
}
|
||||||
|
if !IsTailscale(net.ParseIP(s)) {
|
||||||
|
t.Errorf("IsTailscale(%s) = false", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undtagelsen skal være så smal, som den er navngivet. Naboerne til området
|
||||||
|
// er ikke Tailscale, og en anden unique-local-adresse er stadig indenfor.
|
||||||
|
for _, s := range []string{
|
||||||
|
"100.63.255.255", // lige under 100.64.0.0/10
|
||||||
|
"100.128.0.0", // lige over
|
||||||
|
"fd00::1", // unique-local, men ikke Tailscales
|
||||||
|
"fd7b:115c:a1e0::1",
|
||||||
|
"192.168.1.150",
|
||||||
|
} {
|
||||||
|
if IsTailscale(net.ParseIP(s)) {
|
||||||
|
t.Errorf("IsTailscale(%s) = true, men den ligger uden for området", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Og de af dem, der er indenfor, skal stadig være det.
|
||||||
|
for _, s := range []string{"fd00::1", "fd7b:115c:a1e0::1", "192.168.1.150"} {
|
||||||
|
if !Blocked(net.ParseIP(s)) {
|
||||||
|
t.Errorf("Blocked(%s) = false — muren er væk for mere end Tailscale", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- verdande:rebuild-tables
|
||||||
|
--
|
||||||
|
-- Notekroppe forsegles.
|
||||||
|
--
|
||||||
|
-- Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
|
||||||
|
-- sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem
|
||||||
|
-- hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler
|
||||||
|
-- ned — det ved vi, fordi der ligger sådan nogle i denne installation.
|
||||||
|
--
|
||||||
|
-- # Hvorfor det ikke var gjort for længe siden
|
||||||
|
--
|
||||||
|
-- Kroppen var kopieret to steder mere, og begge kopier var klartekst:
|
||||||
|
--
|
||||||
|
-- * `fold` — en genereret søjle med title || ' ' || body, translittereret, så
|
||||||
|
-- "grøn" kan findes ved at skrive "gron".
|
||||||
|
-- * `notes_fts` — et FTS5-indeks over titel, krop og fold.
|
||||||
|
--
|
||||||
|
-- At forsegle kroppen og lade dem stå ville have været teater: en angriber med
|
||||||
|
-- filen kunne læse hver note ud af indekset. Så de skal begge væk, og søgningen
|
||||||
|
-- skal skrives om til at åbne teksten i Go.
|
||||||
|
--
|
||||||
|
-- # Hvad det koster, og hvornår formen er forkert
|
||||||
|
--
|
||||||
|
-- Søgningen læser nu hver note. Målt: tolv hundrede noter à 3,3 kB koster 33–43 ms
|
||||||
|
-- pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så
|
||||||
|
-- det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne, men med at
|
||||||
|
-- folde og småskrive hver krop; det er lineært, så regn med omtrent 350 ms ved ti
|
||||||
|
-- tusind noter. Dér er det den forkerte form, og svaret er da et *nøglet* indeks:
|
||||||
|
-- en HMAC pr. ord under den samme nøgle. Bemærk hvad det koster, når det bliver
|
||||||
|
-- aktuelt — et nøglet indeks kan slå et helt ord op og ikke andet, så præfiks- og
|
||||||
|
-- delordssøgningen, der findes i dag, ville falde bort.
|
||||||
|
--
|
||||||
|
-- # Hvad der stadig ligger i klartekst
|
||||||
|
--
|
||||||
|
-- Alt andet. Opgavetitler, beskrivelser, kommentarer — og `note_links`, som er
|
||||||
|
-- id'er udledt af kroppen, så en fil røber stadig *hvilke* projekter og opgaver en
|
||||||
|
-- note peger på, men ikke hvad der står. Det er skrevet i SECURITY.md, og det er
|
||||||
|
-- ikke løst her.
|
||||||
|
--
|
||||||
|
-- # Selve ombygningen
|
||||||
|
--
|
||||||
|
-- SQLite kan ikke fjerne en genereret søjle på plads, så tabellen bygges om.
|
||||||
|
-- Rækkerne kopieres med kroppen, som den er: forseglingen sker i Go, hvor nøglen
|
||||||
|
-- er, og en opstart efter denne migration går rækkerne igennem og lukker dem. Til
|
||||||
|
-- den tid kan en søjle indeholde begge dele, og `v1:`-præfikset findes netop for
|
||||||
|
-- at kunne læses uden at gætte.
|
||||||
|
CREATE TABLE notes_sealed (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
archived_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO notes_sealed
|
||||||
|
(id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at)
|
||||||
|
SELECT id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at
|
||||||
|
FROM notes;
|
||||||
|
|
||||||
|
DROP TRIGGER notes_fts_insert;
|
||||||
|
DROP TRIGGER notes_fts_delete;
|
||||||
|
DROP TRIGGER notes_fts_update;
|
||||||
|
DROP TABLE notes_fts;
|
||||||
|
DROP TABLE notes;
|
||||||
|
|
||||||
|
ALTER TABLE notes_sealed RENAME TO notes;
|
||||||
|
|
||||||
|
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
|
||||||
|
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
+125
-83
@@ -3,7 +3,9 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -89,6 +91,24 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
// it, and every caller gets the same answer because none of them can set it.
|
// it, and every caller gets the same answer because none of them can set it.
|
||||||
n.Title = firstLine(n.Body)
|
n.Title = firstLine(n.Body)
|
||||||
|
|
||||||
|
// Forseglet på vej ned, åbnet på vej op.
|
||||||
|
//
|
||||||
|
// Både titel og krop: titlen *er* første linje, så en forseglet krop ved siden
|
||||||
|
// af en læselig titel ville efterlade begyndelsen af hver eneste note i
|
||||||
|
// klartekst — og det er dér, folk skriver hvad noten handler om.
|
||||||
|
//
|
||||||
|
// `n` selv røres ikke. Kalderen har den note i hånden og læser videre i den
|
||||||
|
// bagefter; at forsegle den på plads ville give en note, hvis krop er
|
||||||
|
// ciffertekst, til den, der lige har gemt den.
|
||||||
|
title, err := db.sealValue(n.Title)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal note title: %w", err)
|
||||||
|
}
|
||||||
|
body, err := db.sealValue(n.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal note body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
var projectID any
|
var projectID any
|
||||||
if n.ProjectID != "" {
|
if n.ProjectID != "" {
|
||||||
@@ -108,7 +128,7 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
body = excluded.body,
|
body = excluded.body,
|
||||||
pinned = excluded.pinned,
|
pinned = excluded.pinned,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at`,
|
||||||
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
|
n.ID, projectID, title, body, createdBy, n.Pinned,
|
||||||
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
|
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -182,7 +202,7 @@ func firstLine(body string) string {
|
|||||||
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
|
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
|
||||||
row := db.QueryRowContext(ctx,
|
row := db.QueryRowContext(ctx,
|
||||||
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
|
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
|
||||||
n, err := scanNote(row)
|
n, err := db.scanNote(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -263,99 +283,104 @@ func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note,
|
|||||||
|
|
||||||
// SearchNotes matches title and body, in either spelling of a Danish word.
|
// SearchNotes matches title and body, in either spelling of a Danish word.
|
||||||
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
|
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
|
||||||
if strings.TrimSpace(query) == "" {
|
terms := SearchTerms(query)
|
||||||
|
if len(terms) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
expr := MatchExprOver(query, "title", "body")
|
|
||||||
if expr == "" {
|
// Hver note læses og åbnes.
|
||||||
return nil, nil
|
//
|
||||||
|
// Der er ikke noget indeks at spørge længere, og det er med vilje: `notes_fts`
|
||||||
|
// og den genererede `fold`-søjle var begge klartekstkopier af kroppen, så en
|
||||||
|
// forseglet krop ved siden af dem ville have været teater — en fil ville stadig
|
||||||
|
// røbe hver note. Se 0027.
|
||||||
|
//
|
||||||
|
// Målt frem for gættet: tolv hundrede noter à 3,3 kB koster **33–43 ms** pr.
|
||||||
|
// søgning. Fladen venter 250 ms efter sidste tastetryk, før den spørger, så det
|
||||||
|
// ligger inde i pausen og ses ikke.
|
||||||
|
//
|
||||||
|
// Tiden går ikke med at åbne konvolutterne — fire megabyte AES-GCM er få
|
||||||
|
// millisekunder — men med at folde og småskrive hver krop for hver søgning.
|
||||||
|
// Det er lineært, så regn med omtrent 350 ms ved ti tusind noter, og dér er det
|
||||||
|
// den forkerte form. Svaret er da et *nøglet* indeks: en HMAC pr. ord under
|
||||||
|
// samme nøgle. Bemærk hvad det koster, når det bliver aktuelt — et nøglet
|
||||||
|
// indeks kan slå et helt ord op og ikke andet, så præfiks- og delordssøgningen,
|
||||||
|
// der findes i dag, ville falde bort.
|
||||||
|
//
|
||||||
|
// Papirkurven springes over i SQL frem for i løkken: det er den ene begrænsning,
|
||||||
|
// der kan bruge et indeks, og den fjerner alt hvad der er slettet uden at åbne
|
||||||
|
// det.
|
||||||
|
notes, err := db.notesWhere(ctx, `WHERE deleted_at IS NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
// Ordered by how well it matched, not by when it was last touched.
|
|
||||||
//
|
type scored struct {
|
||||||
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
note Note
|
||||||
// useless at twelve hundred: every one of them was imported the same evening,
|
score int
|
||||||
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
|
||||||
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
|
||||||
// the note actually called "Tesla Model Y" second — which reads as a search
|
|
||||||
// that does not work, because from where the person is sitting it is one.
|
|
||||||
//
|
|
||||||
// bm25 with the title weighted ten times the body. A word in the title is what
|
|
||||||
// the note is *about*; the same word once in a long body is a mention. `fold`
|
|
||||||
// carries the same text transliterated, so it must weigh the same as the body
|
|
||||||
// or a Danish match would outrank an exact one.
|
|
||||||
//
|
|
||||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
|
||||||
// subquery throws it away.
|
|
||||||
found, err := db.notesJoin(ctx, noteColumnsQualified, `
|
|
||||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
|
||||||
WHERE notes.deleted_at IS NULL
|
|
||||||
AND notes_fts MATCH ?
|
|
||||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
|
||||||
LIMIT ?`, expr, limit)
|
|
||||||
if err != nil || len(found) > 0 {
|
|
||||||
return found, err
|
|
||||||
}
|
}
|
||||||
return db.searchNotesInside(ctx, query, limit)
|
found := make([]scored, 0, limit)
|
||||||
|
|
||||||
|
for _, note := range notes {
|
||||||
|
score := scoreNote(note, terms)
|
||||||
|
if score == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = append(found, scored{note: note, score: score})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedste først, og ved lige stand den senest rørte. Uden det andet led er
|
||||||
|
// rækkefølgen mellem to lige gode svar den, SQLite tilfældigvis gav dem, og den
|
||||||
|
// skifter mellem to søgninger på det samme.
|
||||||
|
sort.SliceStable(found, func(i, j int) bool {
|
||||||
|
if found[i].score != found[j].score {
|
||||||
|
return found[i].score > found[j].score
|
||||||
|
}
|
||||||
|
return found[i].note.UpdatedAt.After(found[j].note.UpdatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make([]Note, 0, min(limit, len(found)))
|
||||||
|
for i, f := range found {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, f.note)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchNotesInside finds a word that lives *inside* another word.
|
// scoreNote er hvor godt en note svarer på ordene, eller nul for slet ikke.
|
||||||
//
|
//
|
||||||
// An index is built of tokens, and a token is what a tokenizer decided was a
|
// Erstatter bm25, som forsvandt med indekset. Den er grovere, og det er værd at
|
||||||
// word. `gamerule keepInventory true` is three of them, and the middle one is
|
// vide hvor: bm25 vægter et ord efter hvor sjældent det er i hele samlingen, og
|
||||||
// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and
|
// det kan ikke gøres uden en samling at tælle i. Det, der er beholdt, er den
|
||||||
// gets nothing. Reported as "søgning har ikke det store held", which from where
|
// halvdel, der betød mest — **titlen vejer ti gange kroppen**. Et ord i titlen er
|
||||||
// the person is sitting is exactly what it is: the note is right there, and the
|
// hvad noten *handler om*; det samme ord én gang i en lang krop er en omtale.
|
||||||
// search says there is nothing.
|
|
||||||
//
|
//
|
||||||
// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*`
|
// Alle ord skal findes. Det er den samme regel som før, hvor leddene var bundet
|
||||||
// cannot, because it is not a prefix of anything — and the two are joined with AND,
|
// med AND.
|
||||||
// so the half that works is thrown away by the half that cannot.
|
func scoreNote(note Note, terms []string) int {
|
||||||
//
|
title := FoldDanish(strings.ToLower(note.Title))
|
||||||
// This is not an exotic case in a notes app. A decade of notes is full of
|
body := FoldDanish(strings.ToLower(note.Body))
|
||||||
// commands, identifiers and file names, and every one of them is several words the
|
|
||||||
// index thinks is one: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.
|
|
||||||
//
|
|
||||||
// So: the index answers first, and when it has nothing, the text is read. Every
|
|
||||||
// term has to appear somewhere in the note, anywhere, in any word.
|
|
||||||
//
|
|
||||||
// # What it costs, and when to stop doing it this way
|
|
||||||
//
|
|
||||||
// A LIKE with a leading % cannot use an index; this reads every note. At twelve
|
|
||||||
// hundred notes that is four megabytes and a millisecond or two, and it only runs
|
|
||||||
// when the fast path already came back empty — which is the search that was about
|
|
||||||
// to disappoint somebody. At a hundred thousand notes it would be the wrong shape,
|
|
||||||
// and the answer then is an FTS5 `trigram` index, not a bigger scan.
|
|
||||||
//
|
|
||||||
// It does not rank. bm25 needs a match to rank, and there is no match here — that
|
|
||||||
// is the whole situation. The rows are the most recently touched first, and the
|
|
||||||
// set is small, because the index already said there was nothing in it.
|
|
||||||
func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) {
|
|
||||||
terms := SearchTerms(query)
|
|
||||||
if len(terms) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// `fold` is `title || ' ' || body` with the Danish letters transliterated, so
|
score := 0
|
||||||
// one column covers the title, the body and "gron" for "grøn" at once. LIKE is
|
|
||||||
// case-insensitive for ASCII in SQLite, which is what makes `keepInventory`
|
|
||||||
// answer to "inventory".
|
|
||||||
//
|
|
||||||
// The needle is safe to interpolate into a pattern because `tokenize` splits on
|
|
||||||
// everything that is not a letter or a digit: a term cannot contain % or _, so
|
|
||||||
// there is no wildcard for somebody to smuggle in. It is still a bound
|
|
||||||
// parameter — that is about SQL, and this is about LIKE.
|
|
||||||
where := "WHERE deleted_at IS NULL"
|
|
||||||
args := make([]any, 0, len(terms)+1)
|
|
||||||
for _, term := range terms {
|
for _, term := range terms {
|
||||||
where += " AND fold LIKE ?"
|
needle := FoldDanish(strings.ToLower(term))
|
||||||
args = append(args, "%"+FoldDanish(term)+"%")
|
inTitle := strings.Count(title, needle)
|
||||||
|
inBody := strings.Count(body, needle)
|
||||||
|
if inTitle == 0 && inBody == 0 {
|
||||||
|
// Ét ord, der ikke findes, er et nej for hele noten.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der
|
||||||
|
// nævner alle ordene én gang. Det er den fejl, en ren optælling laver, og
|
||||||
|
// den er let at lave og svær at få øje på.
|
||||||
|
score += 10*min(inTitle, 3) + min(inBody, 5)
|
||||||
}
|
}
|
||||||
args = append(args, limit)
|
return score
|
||||||
|
|
||||||
return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNoteTimes writes the dates a note had somewhere else.
|
// SetNoteTimes writes the dates a note had somewhere else.
|
||||||
@@ -441,7 +466,7 @@ func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any)
|
|||||||
|
|
||||||
var out []Note
|
var out []Note
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
n, err := scanNote(rows)
|
n, err := db.scanNote(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -469,7 +494,11 @@ func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanNote(row scanner) (Note, error) {
|
// scanNote læser en række og åbner det, der er forseglet.
|
||||||
|
//
|
||||||
|
// En metode og ikke en funktion, fordi nøglen sidder på `db`. Det er den eneste
|
||||||
|
// grund; alt andet i den er den samme aflæsning som før.
|
||||||
|
func (db *DB) scanNote(row scanner) (Note, error) {
|
||||||
var n Note
|
var n Note
|
||||||
var projectID, createdBy sql.NullString
|
var projectID, createdBy sql.NullString
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
@@ -491,5 +520,18 @@ func scanNote(row scanner) (Note, error) {
|
|||||||
t := time.Unix(archived.Int64, 0)
|
t := time.Unix(archived.Int64, 0)
|
||||||
n.ArchivedAt = &t
|
n.ArchivedAt = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Åbnet her, ét sted, så ingen kalder kan komme til at læse ciffertekst.
|
||||||
|
//
|
||||||
|
// En værdi skrevet før der var en nøgle kommer tilbage som sig selv — `v1:`
|
||||||
|
// findes netop for at kunne læse en blandet søjle — så en installation, der
|
||||||
|
// endnu ikke har fået sin bagudfyldning, virker imens.
|
||||||
|
for _, field := range []*string{&n.Title, &n.Body} {
|
||||||
|
plain, err := db.unsealValue(*field)
|
||||||
|
if err != nil {
|
||||||
|
return n, fmt.Errorf("open note %s: %w", n.ID, err)
|
||||||
|
}
|
||||||
|
*field = plain
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,3 +277,119 @@ func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
|
|||||||
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
|
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer.
|
||||||
|
//
|
||||||
|
// Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes
|
||||||
|
// gennem fladen. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi,
|
||||||
|
// fordi der ligger sådan nogle i den installation, det her blev bygget til.
|
||||||
|
func TestANoteIsNotStoredInTheClear(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const secretLine = "gamerule keepInventory true"
|
||||||
|
n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine}
|
||||||
|
if err := db.SaveNote(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var title, body string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, secretLine) {
|
||||||
|
t.Errorf("kroppen står i klartekst i rækken: %s", body)
|
||||||
|
}
|
||||||
|
// Og titlen med. Titlen *er* første linje, så en forseglet krop ved siden af en
|
||||||
|
// læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.
|
||||||
|
if strings.Contains(title, "Minecraft") {
|
||||||
|
t.Errorf("titlen står i klartekst i rækken: %s", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kalderens egen note er urørt: den har den i hånden og læser videre i den.
|
||||||
|
if n.Body != "Minecraft Bedrock\n\n"+secretLine {
|
||||||
|
t.Errorf("gemningen ændrede kalderens note: %q", n.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Læst tilbage er den sig selv igen.
|
||||||
|
back, err := db.Note(ctx, n.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.Body != n.Body || back.Title != "Minecraft Bedrock" {
|
||||||
|
t.Errorf("noten kom anderledes tilbage: %+v", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et
|
||||||
|
// indeks, der var en klartekstkopi af den.
|
||||||
|
found, err := db.SearchNotes(ctx, "keep inventory", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades.
|
||||||
|
//
|
||||||
|
// Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver
|
||||||
|
// *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer
|
||||||
|
// igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle.
|
||||||
|
func TestOldNotesAreSealedOnStartup(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Skrevet uden om Go-laget, som en note fra før 0027 ligger.
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO notes (id, title, body, created_by, created_at, updated_at)
|
||||||
|
VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`,
|
||||||
|
userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed, err := db.SealNotes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sealed != 1 {
|
||||||
|
t.Fatalf("%d noter blev lukket, want 1", sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body string
|
||||||
|
var updated int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "hemmeligt") {
|
||||||
|
t.Errorf("den gamle note ligger stadig åben: %s", body)
|
||||||
|
}
|
||||||
|
// `updated_at` er ikke rørt. Det betyder "hvornår skrev dette program noten
|
||||||
|
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||||
|
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville kaste den
|
||||||
|
// rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||||
|
if updated != 200 {
|
||||||
|
t.Errorf("bagudfyldningen flyttede updated_at til %d", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Og den kan læses.
|
||||||
|
back, err := db.Note(ctx, "n-gammel")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back == nil || !strings.Contains(back.Body, "hemmeligt") {
|
||||||
|
t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit
|
||||||
|
// præfiks.
|
||||||
|
again, err := db.SealNotes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again != 0 {
|
||||||
|
t.Errorf("anden kørsel lukkede %d noter om igen", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,81 @@ type DB struct {
|
|||||||
// misplaced key look like a corrupt disk.
|
// misplaced key look like a corrupt disk.
|
||||||
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
|
||||||
|
|
||||||
|
// SealNotes lukker de noter, der stadig ligger åbne.
|
||||||
|
//
|
||||||
|
// Kaldes én gang ved opstart, efter nøglen er sat. Uden den ville kroppene blive
|
||||||
|
// forseglet efterhånden som de bliver *skrevet* — sådan som instansens
|
||||||
|
// indstillinger gør det — og for et arkiv på tolv hundrede importerede noter, som
|
||||||
|
// ingen redigerer igen, betyder det aldrig. En bagudfyldning er den eneste måde,
|
||||||
|
// den her ændring rent faktisk beskytter noget på.
|
||||||
|
//
|
||||||
|
// Idempotent: en note, der allerede er forseglet, kendes på sit præfiks og røres
|
||||||
|
// ikke. Så den kan køre ved hver opstart uden at koste andet end et gennemløb af
|
||||||
|
// id'er og en længde.
|
||||||
|
//
|
||||||
|
// Skriver med SQL frem for gennem SaveNote. SaveNote udleder titlen af kroppen og
|
||||||
|
// bygger links om, og begge dele er allerede rigtige her — det, der skal ske, er
|
||||||
|
// alene, at de to søjler skal skiftes ud med sig selv, lukket.
|
||||||
|
func (db *DB) SealNotes(ctx context.Context) (int, error) {
|
||||||
|
if db.box == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id, title, body FROM notes`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
type open struct{ id, title, body string }
|
||||||
|
var pending []open
|
||||||
|
for rows.Next() {
|
||||||
|
var o open
|
||||||
|
if err := rows.Scan(&o.id, &o.title, &o.body); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if secret.IsSealed(o.body) && (o.title == "" || secret.IsSealed(o.title)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, o)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hele bunken i én transaktion. En halvt forseglet tabel er ikke i stykker —
|
||||||
|
// præfikset gør, at begge slags kan læses — men en afbrudt kørsel, der skal
|
||||||
|
// tages om, er lettere at ræsonnere om, når den enten skete eller ikke gjorde.
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
for _, o := range pending {
|
||||||
|
title, err := db.sealValue(o.title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := db.sealValue(o.body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// `updated_at` røres ikke. Det betyder "hvornår skrev dette program noten
|
||||||
|
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
||||||
|
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville
|
||||||
|
// kaste den rækkefølge væk, importen brugte en dag på at hente hjem.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE notes SET title = ?, body = ? WHERE id = ?`, title, body, o.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(pending), nil
|
||||||
|
}
|
||||||
|
|
||||||
// dsn is the connection string.
|
// dsn is the connection string.
|
||||||
//
|
//
|
||||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||||
|
|||||||
+26
-1
@@ -15,12 +15,37 @@ gameskill:
|
|||||||
# this: a manifest with new fields and an unchanged version is a manifest the
|
# this: a manifest with new fields and an unchanged version is a manifest the
|
||||||
# panel has no reason to re-read, so the fields never appear and the failure is
|
# panel has no reason to re-read, so the fields never appear and the failure is
|
||||||
# silent — the server's settings page simply does not offer them.
|
# silent — the server's settings page simply does not offer them.
|
||||||
version: 4
|
# Bumpet til 6: v5 flyttede imaget til vores egen Gitea, og det virkede ikke.
|
||||||
|
# En rune opdaterer ikke sig selv — panelet skriver rune-rækken fra en
|
||||||
|
# UI-handling, så ændringen her kræver **Runes → verdande → Update**, og
|
||||||
|
# katalog-listen caches ti minutter, så to forsøg lige efter hinanden kan begge
|
||||||
|
# give den gamle version.
|
||||||
|
version: 6
|
||||||
icon: "app"
|
icon: "app"
|
||||||
|
|
||||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||||
# image already declares as its ENTRYPOINT, and startup.exec passes it argv
|
# image already declares as its ENTRYPOINT, and startup.exec passes it argv
|
||||||
# directly rather than through /bin/sh, which does not exist in there.
|
# directly rather than through /bin/sh, which does not exist in there.
|
||||||
|
# GHCR, ikke vores egen Gitea. v5 prøvede det sidste og tog instansen ned.
|
||||||
|
#
|
||||||
|
# Panelets `PullImage` sætter aldrig `RegistryAuth`, så registret SKAL kunne
|
||||||
|
# læses uden legitimation. GHCR kan: dens token-endepunkt svarer 200 og udsteder
|
||||||
|
# en pull-token til hvem som helst. Gitea på gitea.nolimit.dk kan ikke —
|
||||||
|
# `/v2/token?scope=repository:kw/verdande:pull` svarer 401, også efter at repoet
|
||||||
|
# blev gjort offentligt. Instansen kræver login på alt, registret inklusive, og
|
||||||
|
# det er en indstilling for hele instansen frem for for denne pakke.
|
||||||
|
#
|
||||||
|
# Sådan ser det ud, når det går galt, for det tog en time at finde: panelet
|
||||||
|
# river containeren ned for at anvende runen, pullet fejler, `pullImageRetry`
|
||||||
|
# kasserer fejlen, og `ContainerCreate` slår derefter op LOKALT og siger
|
||||||
|
# `No such image`. Serveren står som "running" uden container_id, og udadtil er
|
||||||
|
# det en 502. Fejlen nævner aldrig, at det var et login, der manglede.
|
||||||
|
#
|
||||||
|
# Sådan afgør man det, uden at blive narret: spørg registrets token-endepunkt
|
||||||
|
# med `curl` og se efter en 200 med et token i. Test det ALDRIG med
|
||||||
|
# `docker --config <tom-mappe> pull` — en tom config stopper ikke maskinens
|
||||||
|
# credential-helper, så pullet bliver autentificeret uden at se sådan ud, og
|
||||||
|
# svaret er præcis det modsatte af sandheden.
|
||||||
docker:
|
docker:
|
||||||
image: "ghcr.io/kristianwind/verdande:latest"
|
image: "ghcr.io/kristianwind/verdande:latest"
|
||||||
data_path: /data
|
data_path: /data
|
||||||
|
|||||||
@@ -3366,6 +3366,35 @@ test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page
|
|||||||
await expect(page.locator('h3.group')).toHaveCount(0);
|
await expect(page.locator('h3.group')).toHaveCount(0);
|
||||||
await page.getByPlaceholder('Søg i noter').fill('');
|
await page.getByPlaceholder('Søg i noter').fill('');
|
||||||
|
|
||||||
|
// Rækkerne skal kunne *ses*, ikke bare findes.
|
||||||
|
//
|
||||||
|
// Da listen blev delt i grupper, blev hver gruppe sit eget `<ul>` — og dermed
|
||||||
|
// sit eget flex-element med `overflow-y: auto`. Flex-elementer skrumper som
|
||||||
|
// udgangspunkt, og et element, der skrumper og klipper, viser ingenting: alle
|
||||||
|
// overskrifter stod med deres tal, og der var ikke en eneste note under nogen af
|
||||||
|
// dem. Med tre grupper og en høj skærm var der plads nok til at det ikke skete,
|
||||||
|
// hvilket er hvorfor prøven herover ikke fangede det.
|
||||||
|
//
|
||||||
|
// Et lavt vindue fremtvinger presset. Tolv hundrede noter i tyve måneder gør det
|
||||||
|
// samme i virkeligheden.
|
||||||
|
await page.setViewportSize({ width: 1100, height: 380 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expect(
|
||||||
|
page.locator('li').filter({ hasText: 'Gruppe to' }).first(),
|
||||||
|
'rækkerne blev klippet væk, da der ikke var plads'
|
||||||
|
).toBeVisible();
|
||||||
|
// Og rulningen ligger på kassen om grupperne, ikke på hver liste for sig.
|
||||||
|
const scroller = await page
|
||||||
|
.locator('.list')
|
||||||
|
.evaluate((el) => getComputedStyle(el).overflowY);
|
||||||
|
expect(scroller, 'kassen om grupperne ruller ikke').toBe('auto');
|
||||||
|
const inner = await page
|
||||||
|
.locator('.list ul')
|
||||||
|
.first()
|
||||||
|
.evaluate((el) => getComputedStyle(el).overflowY);
|
||||||
|
expect(inner, 'den enkelte liste ruller stadig for sig').toBe('visible');
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
|
||||||
// Arkivet grupperes på samme måde. Det er dér, det betyder mest.
|
// Arkivet grupperes på samme måde. Det er dér, det betyder mest.
|
||||||
await row.hover();
|
await row.hover();
|
||||||
await row.getByRole('button', { name: /Arkivér/i }).click();
|
await row.getByRole('button', { name: /Arkivér/i }).click();
|
||||||
|
|||||||
@@ -21,6 +21,13 @@
|
|||||||
{ value: 'compatible', label: 'OpenAI-kompatibel (Ollama, LM Studio, …)' }
|
{ value: 'compatible', label: 'OpenAI-kompatibel (Ollama, LM Studio, …)' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Every field error needs somewhere to appear, and this list is the check that
|
||||||
|
// it does. `base_url` had nowhere: a refused address set `errors`, skipped the
|
||||||
|
// toast because the object was not empty, and then rendered nothing — so the
|
||||||
|
// one provider meant for running your own model looked like a save that did
|
||||||
|
// nothing at all. Anything without a slot is shouted rather than dropped.
|
||||||
|
const SHOWN = ['provider', 'model', 'base_url'];
|
||||||
|
|
||||||
// Suggestions, not a closed list: the model field is free text, because a new
|
// Suggestions, not a closed list: the model field is free text, because a new
|
||||||
// model name must not require a new release of this app to be usable.
|
// model name must not require a new release of this app to be usable.
|
||||||
const SUGGESTED_MODEL = {
|
const SUGGESTED_MODEL = {
|
||||||
@@ -56,7 +63,8 @@
|
|||||||
saved = true;
|
saved = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors = e.fields ?? {};
|
errors = e.fields ?? {};
|
||||||
if (!Object.keys(errors).length) app.toast(humanMessage(e));
|
const homeless = Object.keys(errors).filter((field) => !SHOWN.includes(field));
|
||||||
|
if (!Object.keys(errors).length || homeless.length) app.toast(humanMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -105,6 +113,7 @@
|
|||||||
bind:value={settings.model}
|
bind:value={settings.model}
|
||||||
placeholder={SUGGESTED_MODEL[settings.provider] ?? ''}
|
placeholder={SUGGESTED_MODEL[settings.provider] ?? ''}
|
||||||
/>
|
/>
|
||||||
|
{#if errors.model}<p class="error">{errors.model}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if settings.provider === 'compatible'}
|
{#if settings.provider === 'compatible'}
|
||||||
@@ -116,6 +125,7 @@
|
|||||||
bind:value={settings.base_url}
|
bind:value={settings.base_url}
|
||||||
placeholder="http://localhost:11434/v1"
|
placeholder="http://localhost:11434/v1"
|
||||||
/>
|
/>
|
||||||
|
{#if errors.base_url}<p class="error">{errors.base_url}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -629,99 +629,108 @@
|
|||||||
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groups as group (group.key)}
|
<!-- Én rulleboks om alle grupperne.
|
||||||
<!-- Overskriften klæber, mens man ruller. Med tolv hundrede noter er det
|
Før var der ét `<ul>`, og *det* rullede. Da listen blev delt i grupper,
|
||||||
forskellen på at rulle og på at lede: man kan altid se, hvilken måned
|
blev hver gruppe sit eget `<ul>` — og dermed sit eget flex-element med
|
||||||
man er nede i. -->
|
`overflow-y: auto`. Flex-elementer skrumper som udgangspunkt, og et
|
||||||
{#if !group.plain}
|
element, der skrumper og klipper, viser ingenting: alle overskrifter
|
||||||
<h3 class="group">
|
stod med deres tal, og der var ikke en eneste note under nogen af dem.
|
||||||
<!-- Ingen `aria-label`. Den ville erstatte knappens indhold som dens
|
Rulningen hører til kassen om dem alle, ikke til hver af dem. -->
|
||||||
navn, og knappen *er* overskriften — så overskriften ville hedde
|
<div class="list">
|
||||||
"Fold I dag sammen" i stedet for "I dag". `aria-expanded` siger
|
{#each groups as group (group.key)}
|
||||||
tilstanden, som den gør på gruppehovederne i sidebjælken. -->
|
<!-- Overskriften klæber, mens man ruller. Med tolv hundrede noter er det
|
||||||
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
|
forskellen på at rulle og på at lede: man kan altid se, hvilken måned
|
||||||
{#if group.star}
|
man er nede i. -->
|
||||||
<span class="mark favmark" aria-hidden="true">★</span>
|
{#if !group.plain}
|
||||||
{:else}
|
<h3 class="group">
|
||||||
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
|
<!-- Ingen `aria-label`. Den ville erstatte knappens indhold som dens
|
||||||
{/if}
|
navn, og knappen *er* overskriften — så overskriften ville hedde
|
||||||
{group.label}
|
"Fold I dag sammen" i stedet for "I dag". `aria-expanded` siger
|
||||||
<span class="count">{group.notes.length}</span>
|
tilstanden, som den gør på gruppehovederne i sidebjælken. -->
|
||||||
</button>
|
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
|
||||||
</h3>
|
{#if group.star}
|
||||||
{/if}
|
<span class="mark favmark" aria-hidden="true">★</span>
|
||||||
{#if !folded.has(group.key)}
|
{:else}
|
||||||
<ul>
|
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
|
||||||
{#each group.notes as note (note.id)}
|
{/if}
|
||||||
<li>
|
{group.label}
|
||||||
<div class="rowline">
|
<span class="count">{group.notes.length}</span>
|
||||||
<button
|
</button>
|
||||||
class="row"
|
</h3>
|
||||||
class:on={selectedId === note.id}
|
{/if}
|
||||||
class:picked={picked.has(note.id)}
|
{#if !folded.has(group.key)}
|
||||||
onclick={(e) => rowClick(e, note)}
|
<ul>
|
||||||
draggable="true"
|
{#each group.notes as note (note.id)}
|
||||||
ondragstart={(e) => onDragStart(e, note)}
|
<li>
|
||||||
ondragend={() => (dragging = null)}
|
<div class="rowline">
|
||||||
>
|
<button
|
||||||
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
class="row"
|
||||||
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
class:on={selectedId === note.id}
|
||||||
på navn, for så er der ingen overskrift, der siger den, og så er
|
class:picked={picked.has(note.id)}
|
||||||
den det eneste tilbage, der gør. -->
|
onclick={(e) => rowClick(e, note)}
|
||||||
<span class="head">
|
draggable="true"
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
ondragstart={(e) => onDragStart(e, note)}
|
||||||
{#if order === 'title'}
|
ondragend={() => (dragging = null)}
|
||||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
>
|
||||||
{/if}
|
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
||||||
{#if note.project_id}
|
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
||||||
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
på navn, for så er der ingen overskrift, der siger den, og så er
|
||||||
Mærkaten sagde det samme som alle de andre i en grå, der var
|
den det eneste tilbage, der gør. -->
|
||||||
lysere end uddraget — og projektet er dét, man skanner efter,
|
<span class="head">
|
||||||
når man leder i tolv hundrede noter. Nu kan Hjem kendes fra
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
Claude uden at læse ordet. -->
|
{#if order === 'title'}
|
||||||
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||||
<span class="dot" aria-hidden="true"></span>
|
{/if}
|
||||||
{projectName(note.project_id)}
|
{#if note.project_id}
|
||||||
</span>
|
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
||||||
{/if}
|
Mærkaten sagde det samme som alle de andre i en grå, der var
|
||||||
</span>
|
lysere end uddraget — og projektet er dét, man skanner efter,
|
||||||
<span class="preview">{preview(note)}</span>
|
når man leder i tolv hundrede noter. Nu kan Hjem kendes fra
|
||||||
</button>
|
Claude uden at læse ordet. -->
|
||||||
<!-- De to knapper står oven på hinanden i en smal søjle.
|
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
||||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
<span class="dot" aria-hidden="true"></span>
|
||||||
der er tre hundrede bred — og de bruges sjældent, mens
|
{projectName(note.project_id)}
|
||||||
titlen og uddraget læses hele tiden. -->
|
</span>
|
||||||
<div class="rowactions">
|
{/if}
|
||||||
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
</span>
|
||||||
til flere; det her er til den ene, man står med — og i
|
<span class="preview">{preview(note)}</span>
|
||||||
arkivet er det vejen tilbage. -->
|
</button>
|
||||||
<button
|
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||||
class="archive-one"
|
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||||
onclick={() => archiveOne(note)}
|
der er tre hundrede bred — og de bruges sjældent, mens
|
||||||
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
titlen og uddraget læses hele tiden. -->
|
||||||
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
<div class="rowactions">
|
||||||
>
|
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
til flere; det her er til den ene, man står med — og i
|
||||||
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
arkivet er det vejen tilbage. -->
|
||||||
</svg>
|
<button
|
||||||
</button>
|
class="archive-one"
|
||||||
<button
|
onclick={() => archiveOne(note)}
|
||||||
class="star"
|
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
class:on={note.pinned}
|
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||||
onclick={() => favourite(note)}
|
>
|
||||||
aria-pressed={note.pinned}
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
</svg>
|
||||||
>
|
</button>
|
||||||
{note.pinned ? '★' : '☆'}
|
<button
|
||||||
</button>
|
class="star"
|
||||||
|
class:on={note.pinned}
|
||||||
|
onclick={() => favourite(note)}
|
||||||
|
aria-pressed={note.pinned}
|
||||||
|
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||||
|
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||||
|
>
|
||||||
|
{note.pinned ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
{/each}
|
||||||
{/each}
|
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -1007,12 +1016,18 @@
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kassen ruller. Listerne indeni gør ikke: de er mange nu, og et flex-element,
|
||||||
|
der både skrumper og klipper, viser ingenting. */
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-y: auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rækker uden for skærmen koster ingenting.
|
/* Rækker uden for skærmen koster ingenting.
|
||||||
|
|||||||
Reference in New Issue
Block a user