feat: implement unified directory structure with automatic migration for CalDAV and CardDAV #4

Merged
arnef merged 4 commits from bug/3 into main 2026-08-30 12:04:13 +00:00
Showing only changes of commit 5af3a8508b - Show all commits
+32 -6
View File
@@ -9,16 +9,18 @@ collections. A small server-rendered web UI (templ + Tailwind + htmx) at
## Build, test, lint ## Build, test, lint
```bash ```bash
make build # go build -o bin/davserver ./cmd/server make build # go build -o bin/davserver ./cmd/server + ./bin/nidusctl
make run # build + ./bin/davserver -config config.yaml make run # build + ./bin/davserver -config config.yaml
make test # go test ./... -v -race make test # go test ./... -v -race
make lint # golangci-lint run ./... make lint # golangci-lint run ./... (no config file; uses defaults)
make tidy # go mod tidy make tidy # go mod tidy
go test ./internal/store/ -run TestStoreRoundTrip -v # single test make web-deps # install Tailwind CLI + TypeScript (needed once, or after web/package.json changes)
make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ
make web-css # templ-generate + rebuild web/static/app.css (needs `make web-deps` once, Node.js/npm) make web-css # templ-generate + rebuild web/static/app.css
make web-ts # compile web/ts/*.ts to web/static/*.js make web-ts # compile web/ts/*.ts to web/static/*.js
make web-assets # web-css + web-ts (everything under web/static/) make web-assets # web-css + web-ts (everything under web/static/)
make nidusctl # build + run nidusctl with ARGS (e.g. `make nidusctl ARGS="user list"`)
go run ./tools/migrate -config config.yaml # restructure data directory
``` ```
Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
@@ -51,14 +53,15 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
if it's nil. `auth.NewContext(ctx, p)` is the test-only inverse, used to if it's nil. `auth.NewContext(ctx, p)` is the test-only inverse, used to
build authenticated contexts without a real Basic Auth handshake. build authenticated contexts without a real Basic Auth handshake.
- `internal/store` — the single source of truth for all persisted data. - `internal/store` — the single source of truth for all persisted data.
A thin filesystem KV abstraction: `<data_dir>/<user>/<collection>/<objectID>`. A thin filesystem KV abstraction: `<data_dir>/<user>/[calendars|addressbooks|files]/<name>`.
All collection/object names pass through `sanitize()` (via All collection/object names pass through `sanitize()` (via
`filepath.Base` + strip `..`) to prevent path traversal — preserve this `filepath.Base` + strip `..`) to prevent path traversal — preserve this
when adding new store methods. Writes use temp-file + rename for atomicity when adding new store methods. Writes use temp-file + rename for atomicity
(`PutObject`). Locking is sharded per-user (`lockFor(user)`, a (`PutObject`). Locking is sharded per-user (`lockFor(user)`, a
`map[string]*sync.RWMutex` guarded by its own mutex) rather than one `map[string]*sync.RWMutex` guarded by its own mutex) rather than one
global lock, so different users' requests don't serialize against each global lock, so different users' requests don't serialize against each
other. other. `Migrate()` restructures data from the old format (`cal-<name>`,
`card-<name>`) to the new unified layout and is idempotent.
- `internal/caldav` and `internal/carddav` — implement the - `internal/caldav` and `internal/carddav` — implement the
`caldav.Backend`/`carddav.Backend` interfaces from `github.com/emersion/go-webdav` `caldav.Backend`/`carddav.Backend` interfaces from `github.com/emersion/go-webdav`
on top of `store.Store`. Calendars are stored as collections prefixed on top of `store.Store`. Calendars are stored as collections prefixed
@@ -100,6 +103,12 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
shared data is never copied — it's read/written directly under the shared data is never copied — it's read/written directly under the
owner's own `store.Store` namespace, just addressed via the synthetic owner's own `store.Store` namespace, just addressed via the synthetic
name from the grantee's requests. name from the grantee's requests.
**ICSSubscriptions**: read-only calendars backed by remote ICS/webcal
URLs (see `db.CreateICSSubscription`, `internal/db/ics.go`) are exposed
alongside real calendars under `/cal/home/<name>/` (shared namespace;
see `internal/caldav/ics.go`). `Birthdays` is a computed calendar
synthesized from contacts' BDAY fields (see `internal/caldav/birthdays.go`,
`internal/birthdays/birthdays.go`).
- `internal/db` — a small `database/sql` wrapper around - `internal/db` — a small `database/sql` wrapper around
`modernc.org/sqlite` (pure Go, no CGO) at `<data_dir>/nidus.db`. Only `modernc.org/sqlite` (pure Go, no CGO) at `<data_dir>/nidus.db`. Only
one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a
@@ -118,6 +127,9 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
cleans those up in a transaction. `modernc.org/sqlite` has no typed cleans those up in a transaction. `modernc.org/sqlite` has no typed
unique-constraint error, so `isUniqueConstraintErr()` string-matches the unique-constraint error, so `isUniqueConstraintErr()` string-matches the
driver's error message. driver's error message.
**Additional tables**: `birthday_calendars` (per-user display color for
the computed Birthdays calendar), `ics_subscriptions` (remote ICS/webcal
calendar subscriptions), `web_sessions` (server-side session tokens).
- `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`, - `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`,
mounted at the single fixed URL `/files/` for all users (no username in mounted at the single fixed URL `/files/` for all users (no username in
the path either). `NewHandler` caches one `*xwebdav.Handler` per the path either). `NewHandler` caches one `*xwebdav.Handler` per
@@ -128,6 +140,10 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
- `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) to - `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) to
generate bcrypt hashes for ad-hoc testing (no longer needed for normal generate bcrypt hashes for ad-hoc testing (no longer needed for normal
user setup — see `nidusctl user create` below). user setup — see `nidusctl user create` below).
- `tools/migrate` — restructures data directory from old format (`cal-<name>`,
`card-<name>`) to unified layout (`calendars/`, `addressbooks/`,
`files/`). Run automatically on server startup; invoke manually with
`go run ./tools/migrate -config config.yaml`.
- `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl - `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl
-config config.yaml <user|calendar|addressbook> ...`) — the only way to -config config.yaml <user|calendar|addressbook> ...`) — the only way to
create/delete users, calendars, and address books, plus manage sharing create/delete users, calendars, and address books, plus manage sharing
@@ -190,6 +206,14 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
no runtime dependency on Node.js or the `web/` directory being present — no runtime dependency on Node.js or the `web/` directory being present —
Node/npm are only needed when actually changing templates/styles/TS Node/npm are only needed when actually changing templates/styles/TS
(`make web-assets` rebuilds everything under `web/static/`). (`make web-assets` rebuilds everything under `web/static/`).
**Web UI features**: dashboard (calendars/address books/ICS subscriptions
management), login (cookie-based sessions), share management (create/update/
revoke share grants via htmx), files browser (inline preview, upload, download),
contacts manager (vCard import/export, edit fields), calendar view (month/week,
per-calendar colors), account settings (name/email/password). The
synthetic `Birthdays` calendar (computed from contacts' BDAY fields) and
ICS/webcal subscriptions are managed via the web UI just like real
calendars.
## Conventions ## Conventions
@@ -211,3 +235,5 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
- Logging uses `log/slog` structured fields (e.g. `logger.Warn("...", "user", u, "error", err)`), passed down explicitly to every constructor (`NewBackend`, `NewHandler`, `NewMiddleware`) rather than a global logger. - Logging uses `log/slog` structured fields (e.g. `logger.Warn("...", "user", u, "error", err)`), passed down explicitly to every constructor (`NewBackend`, `NewHandler`, `NewMiddleware`) rather than a global logger.
- Config module path is `github.com/yourusername/caldav-server` (go.mod name - Config module path is `github.com/yourusername/caldav-server` (go.mod name
predates the `nidus` repo rename) — import paths still use this, not `nidus`. predates the `nidus` repo rename) — import paths still use this, not `nidus`.
- **Docker/CI**: Docker image pushed to `git.arnef.de/arnef/nidus` on tags (`v*`) or releases (`.github/workflows/docker-release.yml`), multi-arch (`linux/amd64`, `linux/arm64`). Use `docker-compose` for local dev with healthcheck on `/healthz`.
- **Database schema** lives in `internal/db/db.go` `migrate()` — all tables are created with `CREATE TABLE IF NOT EXISTS` and schema changes are applied via `ALTER TABLE` in `migrateAddColumns()`. No external migration framework.