feat(server): runnable binary, Rune manifest, Docker image and CI
The HTTP server serves /healthz, the API namespace and the PWA, with the frontend embedded behind a build tag so a clean checkout compiles without a Node toolchain. /healthz reads the database rather than just answering: a process that cannot reach its own data is not healthy, and a 200 there would keep a broken container in rotation. The image is distroless and static — the SQLite driver is pure Go, so CGO stays off. The Rune manifest uses keep_entrypoint because there is no shell in the image to run a startup command with, and its log watchers match the JSON log format the container actually emits. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b44db32bf7
commit
02c8b1be61
@@ -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
|
||||
@@ -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
|
||||
@@ -23,3 +23,6 @@ node_modules/
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Copied in by the Docker build; never committed.
|
||||
/cmd/verdande/webbuild/
|
||||
|
||||
+69
@@ -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' '<!doctype html><meta charset="utf-8"><title>verdande</title>' \
|
||||
'<p>verdande is running. The web interface is not part of this build.</p>' \
|
||||
> 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"]
|
||||
@@ -0,0 +1,167 @@
|
||||
<h1 align="center">verdande</h1>
|
||||
|
||||
<p align="center">
|
||||
Self-hosted tasks and projects, shared with the people you share the work with.<br>
|
||||
One binary, SQLite inside it, deployed as a Rune in Yggdrasil Panel.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
**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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
// "<!doctype html>" 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}))
|
||||
}
|
||||
@@ -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 [email protected], 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":"([^"]+)"'
|
||||
Reference in New Issue
Block a user