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":"([^"]+)"'