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 <noreply@anthropic.com>
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
|
*.swp
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.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
|
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 (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
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/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // 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
|
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
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // 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 h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
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=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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/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 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/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=
|
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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
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=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$!!!not-base64!!!$aGFzaA",
|
||||||
"$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0$", // empty digest
|
"$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],
|
good[:len(good)-8],
|
||||||
}
|
}
|
||||||
for _, h := range broken {
|
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 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":"([^"]+)"'
|
||||||
Reference in New Issue
Block a user