diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1471511
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,63 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+# A second push to the same branch makes the first run's result irrelevant.
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ go:
+ name: Go
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26"
+ cache: true
+
+ - 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 ./...
+
+ docker:
+ name: Docker image builds
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+
+ # 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.
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: false
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..86095a8
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,52 @@
+name: Release
+
+# Tagging is the release trigger: semver tags only, so a stray tag does not publish.
+on:
+ push:
+ tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ image:
+ name: Build and publish image
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: docker/setup-qemu-action@v3
+ - uses: docker/setup-buildx-action@v3
+
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository }}
+ # v1.4.2 publishes 1.4.2, 1.4 and 1 — so a Rune pinned to :1 gets
+ # patches without ever crossing a major version.
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ type=raw,value=latest
+
+ # arm64 is not optional: a homelab is as likely to be a Raspberry Pi or an
+ # Apple silicon VM as it is to be an x86 box.
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ build-args: |
+ VERSION=${{ github.ref_name }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index dcda4bd..e97303a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ node_modules/
*.swp
.idea/
.vscode/
+
+# Copied in by the Docker build; never committed.
+/cmd/verdande/webbuild/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0349aaa
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,69 @@
+# verdande — one static binary, one /data volume, no external database.
+#
+# Three stages: build the PWA, compile the binary with the PWA embedded in it, and
+# copy that single file into a distroless image. What ships has no shell, no package
+# manager and no libc — the attack surface of the running container is the binary.
+
+# --- 1. the web interface ----------------------------------------------------
+FROM node:22-alpine AS web
+
+WORKDIR /src
+COPY web/ ./
+
+# The frontend is built when it is present. During backend-only development it is
+# not, and the build must still produce a working image rather than failing on a
+# missing directory — so this stage falls back to a placeholder page. The Go stage
+# embeds whatever lands in /src/build either way.
+RUN if [ -f package.json ]; then \
+ npm ci --no-audit --no-fund && npm run build; \
+ else \
+ mkdir -p build && \
+ printf '%s\n' '
verdande' \
+ 'verdande is running. The web interface is not part of this build.
' \
+ > build/index.html; \
+ fi
+
+# --- 2. the binary -----------------------------------------------------------
+FROM golang:1.26-alpine AS build
+
+WORKDIR /src
+
+# Dependencies first: this layer is cached until go.mod or go.sum actually changes,
+# which is far less often than the source does.
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+COPY --from=web /src/build ./cmd/verdande/webbuild
+
+ARG VERSION=dev
+
+# CGO off is what makes this binary static, and it is only possible because the
+# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
+# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
+RUN CGO_ENABLED=0 GOOS=linux go build \
+ -tags embedweb \
+ -trimpath \
+ -ldflags="-s -w -buildid= -X main.version=${VERSION}" \
+ -o /verdande ./cmd/verdande
+
+# --- 3. what actually ships --------------------------------------------------
+FROM gcr.io/distroless/static-debian12:nonroot
+
+# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
+# so the container needs a tz database and CA certificates for outbound SMTP.
+COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
+ENV ZONEINFO=/zoneinfo.zip
+
+COPY --from=build /verdande /verdande
+
+# Everything that survives a redeploy lives here: the database, uploaded files and
+# nightly backups.
+VOLUME ["/data"]
+ENV VERDANDE_DATA_DIR=/data \
+ VERDANDE_ADDR=:8080
+
+EXPOSE 8080
+USER nonroot:nonroot
+
+ENTRYPOINT ["/verdande"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..edb30a6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,167 @@
+verdande
+
+
+ Self-hosted tasks and projects, shared with the people you share the work with.
+ One binary, SQLite inside it, deployed as a Rune in Yggdrasil Panel.
+
+
+---
+
+**Verdande** is one of the three Norns — the one who spins *that which is becoming*.
+Not what has been, and not what is fated to be: the thing you are in the middle of.
+Which is what a to-do list is.
+
+## Status
+
+Early. The foundation is built and tested; the API and web interface are being
+written. See [What works today](#what-works-today).
+
+## Why this exists
+
+Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
+integrations that a hosted product will not build for you: a real CalDAV server, a
+mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
+
+## What it does
+
+- **Quick add that reads what you type.** `betal moms i morgen kl 10 p1 #Firma
+ @regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
+ in Firma, labelled regnskab. Danish and English, mixed freely in one line.
+- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
+ views.
+- **Sharing.** Internal users are created by an admin; anyone else joins through an
+ invite link sent to their email. Owner, editor and viewer roles.
+- **Live sync.** A change made by somebody else appears without a refresh.
+- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
+- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
+ PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
+- **A PWA that works on a phone**, installable, with swipe gestures and offline
+ reading of what it has already cached.
+
+## What works today
+
+| Area | State |
+|---|---|
+| SQLite schema, migrations, WAL | Done, tested |
+| Full-text search, Danish-aware | Done, tested |
+| Quick-add parser (Danish + English) | Done, tested |
+| Argon2id passwords, tokens, TOTP, recovery codes | Done, tested |
+| Project roles and permission checks | Done, tested |
+| HTTP server, health check, static hosting | Done |
+| REST API under `/api/v1` | Next |
+| Web interface | Next |
+| Sharing, realtime, import/export, integrations | Planned — see the roadmap |
+
+Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an
+accented `o` and a Danish-first app that cannot do this is broken for its own users.
+
+## Running it
+
+### As a Rune in Yggdrasil Panel
+
+1. **Runes → Carve a rune (upload)** and pick [`rune/verdande.yaml`](rune/verdande.yaml).
+2. Create a server from it.
+3. Set **Public address** to the address you will actually reach it on. verdande
+ cannot work this out for itself — the host port is not chosen until the server
+ exists — and invite links, password resets and calendar feeds are all built from
+ it. `{{PUBLIC_URL}}` is the default and is usually right.
+4. Point the SMTP settings at your mail server. Leave the host blank and verdande
+ shows invite links in the app instead of emailing them, which is fine for a
+ single user and no use at all for sharing.
+5. Start it, open the URL, create the first account. That account is the admin.
+
+### With Docker directly
+
+```bash
+docker run -d --name verdande \
+ -p 8080:8080 \
+ -v verdande-data:/data \
+ -e VERDANDE_BASE_URL=https://todo.example.dk \
+ ghcr.io/kristianwind/verdande:latest
+```
+
+### From source
+
+```bash
+go run ./cmd/verdande
+```
+
+Listens on `:8080` and puts its data in `/data`, so for local work set
+`VERDANDE_DATA_DIR` somewhere writable:
+
+```bash
+VERDANDE_DATA_DIR=./data VERDANDE_DEV=true go run ./cmd/verdande
+```
+
+A plain `go build` produces the API without the web interface — the frontend is
+embedded only when built with `-tags embedweb`, so a clean checkout compiles
+without a Node toolchain installed.
+
+## Configuration
+
+Everything is an environment variable; there is no config file. A Rune has nowhere
+to put one that the operator would ever see.
+
+| Variable | Default | What it does |
+|---|---|---|
+| `VERDANDE_BASE_URL` | `http://localhost:8080` | The public address. Invite links, password resets and ICS feeds are built from it. |
+| `VERDANDE_ADDR` | `:8080` | Listen address. |
+| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
+| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
+| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
+| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
+| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
+| `VERDANDE_SMTP_PORT` | `587` | |
+| `VERDANDE_SMTP_USER` | — | |
+| `VERDANDE_SMTP_PASS` | — | |
+| `VERDANDE_SMTP_FROM` | `verdande@localhost` | Sender address. |
+| `VERDANDE_SMTP_STARTTLS` | `true` unless port 465 | Port 465 is TLS from the first byte; everything else negotiates. |
+| `VERDANDE_SMTP_INSECURE` | `false` | Skip certificate verification. For a self-signed internal mail server, and nothing else. |
+| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
+
+## What lives in `/data`
+
+```
+/data
+├── verdande.db SQLite, WAL mode
+├── files/ attachments
+└── backups/ nightly snapshots, 14 days
+```
+
+Back up the whole directory. If you copy `verdande.db` on its own while the server
+is running you will get a database missing its most recent writes — the `-wal` file
+is part of it.
+
+## Roadmap
+
+1. **MVP** — auth, projects, sections, tasks, sub-tasks, priorities, due dates,
+ quick add, Today and Upcoming, search, keyboard shortcuts.
+2. **Sharing** — invites, roles, assignees, comments, attachments, activity log,
+ realtime sync, notifications.
+3. **Pro parity** — recurring tasks, reminders, filter queries, board and calendar
+ views, durations, ICS feeds, templates, automatic backups.
+4. **Import/export** — Todoist CSV and official export in, CSV/JSON/ICS out.
+5. **Integrations** — CalDAV server, mail-to-task, Gmail, MCP server for Claude,
+ and a provider-agnostic AI layer that also talks to a local model.
+6. **Publication** — documentation, landing page, open-source scaffolding.
+
+## Development
+
+```bash
+go test ./... # everything
+go test -race ./... # what CI runs
+gofmt -l . # must print nothing
+```
+
+Commits follow [Conventional Commits](https://www.conventionalcommits.org/);
+releases are semver tags, which build and publish the image.
+
+## Non-goals
+
+Karma and points. Team workspaces and organisations. Native apps. Mail integrations
+beyond Gmail and the forwarding address. Languages beyond Danish and English. A sync
+protocol of its own — CalDAV and the REST API *are* the sync.
+
+## License
+
+Not yet chosen. MIT is the intention when this is published.
diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go
new file mode 100644
index 0000000..6ab2d4c
--- /dev/null
+++ b/cmd/verdande/main.go
@@ -0,0 +1,117 @@
+// Command verdande is the whole application: API, WebSocket, background jobs and
+// the web interface, in one binary with SQLite underneath. There is no second
+// process to run and no database to provision.
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/kristianwind/verdande/internal/config"
+ "github.com/kristianwind/verdande/internal/httpapi"
+ "github.com/kristianwind/verdande/internal/store"
+)
+
+// version is stamped at build time: -ldflags "-X main.version=v1.2.3"
+var version = "dev"
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+ log := httpapi.NewLogger(cfg.Dev)
+ log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
+
+ // The data volume is the only state that matters; create the whole layout up
+ // front so a failure here is a startup error rather than a surprise at the
+ // first upload or the first backup at three in the morning.
+ for _, dir := range []string{cfg.DataDir, cfg.FilesDir(), cfg.BackupsDir()} {
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return fmt.Errorf("create %s: %w", dir, err)
+ }
+ }
+
+ db, err := store.Open(cfg.DBPath())
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+ log.Info("database ready", "path", db.Path())
+
+ srv := &http.Server{
+ Addr: cfg.Addr,
+ Handler: httpapi.New(cfg, db, log, webAssets(log)),
+ ReadHeaderTimeout: 10 * time.Second,
+ // No WriteTimeout: it would also cap the WebSocket and any long file
+ // download. Per-handler timeouts do that job with the right granularity.
+ IdleTimeout: 120 * time.Second,
+ }
+
+ errc := make(chan error, 1)
+ go func() {
+ log.Info("listening", "addr", cfg.Addr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ errc <- err
+ }
+ }()
+
+ // SIGTERM is what Docker — and so the Yggdrasil panel — sends on stop. Draining
+ // in-flight requests before exiting is what keeps a restart from showing up as
+ // a handful of failed saves.
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+
+ select {
+ case err := <-errc:
+ return fmt.Errorf("listen: %w", err)
+ case sig := <-stop:
+ log.Info("shutting down", "signal", sig.String())
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ return fmt.Errorf("shutdown: %w", err)
+ }
+
+ // WAL mode leaves recent writes in the -wal file. Checkpointing on the way out
+ // means a backup taken of a stopped container is complete.
+ if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
+ log.Warn("wal checkpoint on shutdown", "err", err)
+ }
+ log.Info("stopped")
+ return nil
+}
+
+// webAssets returns the built frontend if it was compiled into this binary.
+//
+// The web build is embedded through a build tag so that `go build ./...` works on a
+// clean checkout with no Node toolchain: without the tag the app serves the API
+// alone and says so, rather than failing to compile because web/build is missing.
+func webAssets(log logger) fs.FS {
+ assets, err := embeddedWeb()
+ if err != nil {
+ log.Warn("no web assets in this build; serving the API only", "err", err)
+ return nil
+ }
+ return assets
+}
+
+type logger interface {
+ Warn(msg string, args ...any)
+}
diff --git a/cmd/verdande/web_embed.go b/cmd/verdande/web_embed.go
new file mode 100644
index 0000000..ccca3e2
--- /dev/null
+++ b/cmd/verdande/web_embed.go
@@ -0,0 +1,16 @@
+//go:build embedweb
+
+package main
+
+import (
+ "embed"
+ "io/fs"
+)
+
+// The built SvelteKit app. Present only in a release build, which runs the frontend
+// build first and then compiles with `-tags embedweb`.
+//
+//go:embed all:webbuild
+var webFS embed.FS
+
+func embeddedWeb() (fs.FS, error) { return fs.Sub(webFS, "webbuild") }
diff --git a/cmd/verdande/web_noembed.go b/cmd/verdande/web_noembed.go
new file mode 100644
index 0000000..7fbc8e4
--- /dev/null
+++ b/cmd/verdande/web_noembed.go
@@ -0,0 +1,15 @@
+//go:build !embedweb
+
+package main
+
+import (
+ "errors"
+ "io/fs"
+)
+
+// Without the embedweb tag there is no frontend in the binary. This is the normal
+// state during backend development and on a clean checkout with no Node installed:
+// the API runs, and anything that is not an API route says so in plain text.
+func embeddedWeb() (fs.FS, error) {
+ return nil, errors.New("built without -tags embedweb")
+}
diff --git a/go.mod b/go.mod
index fb7141a..fb89a6c 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,12 @@ module github.com/kristianwind/verdande
go 1.26.3
-require modernc.org/sqlite v1.56.0
+require (
+ github.com/go-chi/chi/v5 v5.3.1
+ github.com/pquerna/otp v1.5.0
+ golang.org/x/crypto v0.55.0
+ modernc.org/sqlite v1.56.0
+)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
@@ -10,9 +15,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/pquerna/otp v1.5.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index ab554cc..02ace66 100644
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,11 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
+github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -13,12 +16,14 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index ba58142..6106d0f 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -77,7 +77,7 @@ func TestMalformedHashNeverVerifies(t *testing.T) {
"$argon2id$v=19$m=0,t=0,p=0$c2FsdHNhbHRzYWx0$aGFzaA", // zero parameters
"$argon2id$v=19$m=19456,t=2,p=1$!!!not-base64!!!$aGFzaA",
"$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0$", // empty digest
- good[:len(good)-1], // digest truncated by one character
+ good[:len(good)-1], // digest truncated by one character
good[:len(good)-8],
}
for _, h := range broken {
diff --git a/internal/httpapi/context.go b/internal/httpapi/context.go
new file mode 100644
index 0000000..52c95f6
--- /dev/null
+++ b/internal/httpapi/context.go
@@ -0,0 +1,13 @@
+package httpapi
+
+import (
+ "context"
+ "net/http"
+ "time"
+)
+
+// contextWithTimeout bounds a single handler's work without outliving the request
+// itself: if the client has already gone away, the derived context is cancelled too.
+func contextWithTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(r.Context(), d)
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
new file mode 100644
index 0000000..18f2705
--- /dev/null
+++ b/internal/httpapi/server.go
@@ -0,0 +1,211 @@
+// Package httpapi wires verdande's HTTP surface: the REST API under /api/v1, the
+// WebSocket endpoint, and the static PWA that the same binary serves.
+package httpapi
+
+import (
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+
+ "github.com/kristianwind/verdande/internal/config"
+ "github.com/kristianwind/verdande/internal/store"
+)
+
+type Server struct {
+ cfg *config.Config
+ db *store.DB
+ log *slog.Logger
+ web fs.FS
+ router chi.Router
+}
+
+// New builds the router. `web` is the built SvelteKit app, embedded into the binary
+// at build time; a nil value serves an explanatory placeholder instead so that a
+// backend-only build still runs and reports its health.
+func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
+ s := &Server{cfg: cfg, db: db, log: log, web: web}
+
+ r := chi.NewRouter()
+ r.Use(middleware.RequestID)
+ r.Use(middleware.RealIP)
+ r.Use(requestLogger(log))
+ r.Use(middleware.Recoverer)
+ r.Use(middleware.Timeout(30 * time.Second))
+ r.Use(securityHeaders)
+
+ // Unauthenticated, and deliberately outside /api/v1: the Rune healthcheck and
+ // Docker both need this to answer before anyone has logged in.
+ r.Get("/healthz", s.handleHealth)
+
+ r.Route("/api/v1", func(r chi.Router) {
+ r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+ })
+ })
+
+ r.NotFound(s.serveWeb)
+
+ s.router = r
+ return s
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) }
+
+// handleHealth reports whether verdande can actually serve requests, which means
+// reaching the database — a process that is up but cannot read its own data is not
+// healthy, and answering 200 for it would keep a broken container in rotation.
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := contextWithTimeout(r, 2*time.Second)
+ defer cancel()
+
+ var one int
+ if err := s.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
+ s.log.Error("health check failed", "err", err)
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{
+ "status": "unhealthy",
+ "error": "database unreachable",
+ })
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// serveWeb serves the PWA, falling back to index.html so that client-side routes
+// survive a page reload. A request under /api that reaches here is a genuine 404 and
+// must say so in JSON rather than being handed the HTML shell — a client parsing
+// "" as a JSON error message is a confusing way to learn about a typo.
+func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ if s.web == nil {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
+ return
+ }
+
+ name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
+ if name == "" || name == "." {
+ name = "index.html"
+ }
+
+ f, err := s.web.Open(name)
+ if errors.Is(err, fs.ErrNotExist) {
+ // A client-side route: hand back the shell and let the app route it.
+ name = "index.html"
+ f, err = s.web.Open(name)
+ }
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ defer f.Close()
+
+ info, err := f.Stat()
+ if err != nil || info.IsDir() {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ seeker, ok := f.(interface {
+ Read([]byte) (int, error)
+ Seek(int64, int) (int64, error)
+ })
+ if !ok {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot serve asset"})
+ return
+ }
+
+ // Hashed build assets are immutable; the shell must never be cached, or a
+ // deploy leaves people on the previous version until they hard-reload.
+ if strings.HasPrefix(name, "_app/immutable/") {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ http.ServeContent(w, r, name, info.ModTime(), seeker)
+}
+
+// securityHeaders sets the headers that are the same on every response. The CSP is
+// strict because verdande serves only its own assets: there is no CDN to allow, and
+// nothing in the app loads a script from anywhere else.
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Referrer-Policy", "same-origin")
+ h.Set("Content-Security-Policy", strings.Join([]string{
+ "default-src 'self'",
+ "img-src 'self' data: blob:",
+ "style-src 'self' 'unsafe-inline'",
+ "script-src 'self'",
+ "connect-src 'self'",
+ "frame-ancestors 'none'",
+ "base-uri 'none'",
+ "form-action 'self'",
+ }, "; "))
+ next.ServeHTTP(w, r)
+ })
+}
+
+func requestLogger(log *slog.Logger) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
+ next.ServeHTTP(ww, r)
+
+ level := slog.LevelInfo
+ if ww.Status() >= 500 {
+ level = slog.LevelError
+ }
+ // The health check runs every few seconds forever; logging it at info
+ // buries everything else.
+ if r.URL.Path == "/healthz" && ww.Status() < 400 {
+ level = slog.LevelDebug
+ }
+ log.Log(r.Context(), level, "request",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", ww.Status(),
+ "bytes", ww.BytesWritten(),
+ "duration", time.Since(start).Round(time.Millisecond).String(),
+ // RealIP has already resolved this through the proxy headers, so
+ // behind a Cloudflare Tunnel it is the caller and not the tunnel.
+ // The panel's log watchers roll failed logins up per address.
+ "remote", r.RemoteAddr,
+ "request_id", middleware.GetReqID(r.Context()),
+ )
+ })
+ }
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ // The status line is already sent, so there is nothing to report to the
+ // client; this only ever means a dropped connection.
+ slog.Debug("write json response", "err", err)
+ }
+}
+
+// NewLogger returns the process logger. Text in development because a person is
+// reading it; JSON in production because the panel and log tooling are.
+func NewLogger(dev bool) *slog.Logger {
+ if dev {
+ return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ }
+ return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
+}
diff --git a/rune/verdande.yaml b/rune/verdande.yaml
new file mode 100644
index 0000000..4d09d15
--- /dev/null
+++ b/rune/verdande.yaml
@@ -0,0 +1,124 @@
+gameskill:
+ id: verdande
+ name: "verdande"
+ category: "Apps"
+ description: >
+ Self-hosted tasks and projects, shared with other people. Natural-language
+ quick add in Danish and English, sub-tasks, labels, saved filters, board and
+ calendar views, recurring tasks, reminders, comments and attachments. One
+ binary with SQLite inside it — no database to set up. Set BASE_URL to the
+ address you reach it on, point the SMTP settings at your mail server for
+ invites and reminders, then start it and open the URL to create the first
+ admin account.
+ author: "kristianwind"
+ version: 1
+ icon: "app"
+
+ # 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
+ # directly rather than through /bin/sh, which does not exist in there.
+ docker:
+ image: "ghcr.io/kristianwind/verdande:latest"
+ data_path: /data
+ keep_entrypoint: true
+
+ variables:
+ # verdande cannot work out its own public address: the host port is not chosen
+ # until the server is created. {{PUBLIC_URL}} is what the panel knows and this
+ # is what invite links, password resets and ICS feeds are built from — get it
+ # wrong and the links in your email point somewhere that does not answer.
+ - key: VERDANDE_BASE_URL
+ name: "Public address (used in invite and reset links)"
+ type: string
+ default: "{{PUBLIC_URL}}"
+ required: true
+
+ - key: VERDANDE_SMTP_HOST
+ name: "SMTP host (blank = show invite links in the app instead of mailing them)"
+ type: string
+ default: ""
+ - key: VERDANDE_SMTP_PORT
+ name: "SMTP port"
+ type: int
+ default: 587
+ min: 1
+ max: 65535
+ - key: VERDANDE_SMTP_USER
+ name: "SMTP username"
+ type: string
+ default: ""
+ - key: VERDANDE_SMTP_PASS
+ name: "SMTP password"
+ type: string
+ default: ""
+ secret: true
+ - key: VERDANDE_SMTP_FROM
+ name: "Sender address"
+ type: string
+ default: ""
+ pattern: '^$|^[^@\s]+@[^@\s]+\.[^@\s]+$'
+ hint: "must be an email address like verdande@example.dk, or blank to leave it unset"
+ - key: VERDANDE_SMTP_STARTTLS
+ name: "Use STARTTLS (turn off only for port 465, which is TLS from the start)"
+ type: bool
+ default: true
+
+ - key: VERDANDE_SESSION_TTL
+ name: "How long a login lasts (Go duration, e.g. 720h for 30 days)"
+ type: string
+ default: "720h"
+ pattern: '^[0-9]+(ns|us|ms|s|m|h)$'
+ hint: "must be a duration like 720h, 30m or 45s"
+
+ install:
+ # The image has no shell, so the install step borrows one. It only has to make
+ # the directory layout exist; verdande creates and migrates its own database on
+ # first start.
+ image: "busybox:latest"
+ script: |
+ set -eu
+ mkdir -p /data/files /data/backups
+ echo "verdande data directory ready at /data"
+
+ startup:
+ # No command: with keep_entrypoint the image's own ENTRYPOINT (/verdande) runs,
+ # and everything is configured through the environment above.
+ done_regex: '"msg":"listening"'
+
+ ports:
+ - { name: web, default: 8080, protocol: tcp }
+
+ # Everything worth keeping is under /data. The -wal and -shm files are included
+ # deliberately: a WAL-mode SQLite database copied without them can be missing the
+ # most recent writes.
+ backup:
+ include: ["."]
+
+ # The container logs JSON — VERDANDE_DEV is off in production — so these patterns
+ # match the JSON form, not the human-readable one seen when developing locally.
+ watchers:
+ - name: "Database errors"
+ pattern: 'database is locked|disk I/O error|database disk image is malformed'
+ threshold: 1
+ window_secs: 300
+ action: kvasir
+ - name: "Failed logins"
+ pattern: '"path":"/api/v1/auth/login".*"status":401'
+ threshold: 10
+ window_secs: 300
+ action: notify
+ - name: "Server errors"
+ pattern: '"status":5[0-9][0-9]'
+ threshold: 5
+ window_secs: 300
+ action: kvasir
+
+ events:
+ # Capture group 1 is the client address, which the panel rolls up per source —
+ # so a brute-force run shows up as one row per address per hour.
+ - key: login_failed
+ label: "Failed login"
+ match: '"path":"/api/v1/auth/login".*"status":401.*"remote":"([^"]+)"'
+ - key: http_5xx
+ label: "HTTP 5xx"
+ match: '"status":5[0-9][0-9].*"remote":"([^"]+)"'