Move users, calendars, and address books from config.yaml into the database

BREAKING CHANGE: the users:/config-based collection setup is gone. All
user, calendar, and address-book data now lives in the SQLite DB
(internal/db) and is managed exclusively via nidusctl or the web UI.
Existing deployments must recreate their users after upgrading:
  nidusctl user create <username>
  nidusctl calendar create <username> <name>
  nidusctl addressbook create <username> <name>

- internal/db: new users, calendars, addressbooks tables with FK cascade
  delete; foreign_keys pragma enabled; internal/db/users.go implements
  full CRUD + bcrypt auth (CreateUser, VerifyPassword, ListUsers,
  CreateCalendar/AddressBook, etc).
- internal/config: removed Users/UserConfig entirely.
- internal/auth: Basic Auth now checks credentials via db.DB instead of
  cfg.Users.
- internal/caldav, internal/carddav: ListCalendars/ListAddressBooks and
  Create/Delete now backed by the DB.
- internal/web: login uses db.VerifyPassword; new resources.go adds
  create/delete handlers for calendars/address books at
  /web/resources/{calendar,addressbook}; dashboard gained create forms
  and per-card delete buttons (templ + htmx, no hyperscript).
- tools/nidusctl: new user create/delete/list/passwd commands (masked
  interactive password prompt via golang.org/x/term) plus create/delete/
  list subcommands for calendar/addressbook.
- cmd/server/main.go: pre-creates on-disk collections from the DB at
  startup instead of cfg.Users; warns when no users exist yet.
- Updated tests to seed data via the DB; added resources_test.go for the
  new web UI handlers.
- README.md and .github/copilot-instructions.md updated to document the
  new nidusctl commands and the DB-backed architecture.

Verified end-to-end against a live test server: nidusctl user/calendar/
addressbook create, DAV Basic Auth PROPFIND, web login, dashboard
rendering, and web UI create/delete of resources all confirmed working.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-19 12:43:34 +02:00
co-authored by Copilot
parent 3dc49b6b22
commit 7d4f28de3c
25 changed files with 1440 additions and 365 deletions
+49 -24
View File
@@ -29,20 +29,22 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
## Architecture
- `cmd/server/main.go` — entrypoint. Loads config, builds the `slog.Logger`,
constructs the `store.Store`, pre-creates each user's configured
calendars/address-books as collections, wires up `auth.Middleware`, and
builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`,
constructs the `store.Store` and `db.DB`, lists all users/calendars/
address-books from the DB (`dbase.ListUsers`/`ListCalendars`/
`ListAddressBooks`) to pre-create their collections on disk, warns if
zero users exist (`nidusctl user create ...`), wires up `auth.Middleware`,
and builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`,
`/files/`, `/.well-known/{caldav,carddav}`, `/healthz` (unauthenticated),
and `/` (welcome page on GET/HEAD only; any other method — e.g. a WebDAV
client pointed at the wrong URL — gets `405` instead of a misleading
`200`).
- `internal/config` — YAML config loading (`config.Load`), defaults
(`applyDefaults`), and validation (`validate`). `Config.Users` is a
`map[string]UserConfig` keyed by username; each user has a bcrypt
`Password`, `Calendars`, and `AddressBooks` lists that seed collection
names.
(`applyDefaults`), and validation (`validate`). Holds only server/auth/
storage/logging/TLS settings — **no user, calendar, or address-book
data**; all of that lives in `internal/db` now (see below).
- `internal/auth` — HTTP Basic Auth middleware (`auth.Middleware.Wrap`).
Validates credentials against `cfg.Users` via bcrypt, then stores a
Takes a `*db.DB` and validates credentials via `dbase.GetUser` +
`dbase.VerifyPassword` (bcrypt), then stores a
`*Principal{Username, DisplayName, Email}` in the request context.
Downstream code retrieves it with `auth.FromContext(ctx)` — every backend
method needs this and returns `webdav.NewHTTPError(http.StatusUnauthorized, ...)`
@@ -86,8 +88,9 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
(`QueryCalendarObjects`) currently list all objects and filter in-memory
via `caldav.Filter` — fine for small collections, not optimized for
scale.
**Sharing**: both backends take an `*db.DB` (may be `nil` to disable
sharing). A calendar/address book shared with a user is exposed under
**Sharing**: both backends take an `*db.DB` (required — used for both
the base `ListCalendars`/`ListAddressBooks`/`Create*`/`Delete*`
operations and sharing). A calendar/address book shared with a user is exposed under
the synthetic local name `<owner>~<name>` (see `sharedNameSep`,
`sharedCalendarName`/`sharedBookName`) in that user's own home-set —
`resolveCalendar`/`resolveBook` split the local name back into
@@ -101,10 +104,20 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
`modernc.org/sqlite` (pure Go, no CGO) at `<data_dir>/nidus.db`. Only
one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a
single writer; this is intentionally simple and not meant to scale to
heavy concurrent write load — fine for sharing grants (and future user
management), not a general-purpose data store (that's what
`internal/store` is for). Schema lives in `migrate()`; there's no
heavy concurrent write load. Schema lives in `migrate()`; there's no
migration framework, just idempotent `CREATE TABLE IF NOT EXISTS`.
Foreign keys are enabled per-connection (`?_pragma=foreign_keys(1)` in
the DSN). **This is now the single source of truth for users,
calendars, and address books** (`internal/db/users.go`):
`CreateUser`/`SetPassword`/`DeleteUser`/`GetUser`/`VerifyPassword`/
`ListUsers`, and `CreateCalendar`/`DeleteCalendar`/`ListCalendars`,
`CreateAddressBook`/`DeleteAddressBook`/`ListAddressBooks`. `users` is
the parent table; `calendars`/`addressbooks` cascade-delete via FK on
`DeleteUser`; `calendar_shares`/`addressbook_shares`/`web_sessions`
reference usernames as plain strings (no FK) so `DeleteUser` explicitly
cleans those up in a transaction. `modernc.org/sqlite` has no typed
unique-constraint error, so `isUniqueConstraintErr()` string-matches the
driver's error message.
- `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`,
mounted at the single fixed URL `/files/` for all users (no username in
the path either). `NewHandler` caches one `*xwebdav.Handler` per
@@ -113,23 +126,27 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
`LockSystem` — the handler (and its lock table) must be created once and
reused, not per-request, or LOCK/UNLOCK state resets on every call.
- `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) to
generate bcrypt hashes for `config.yaml`.
generate bcrypt hashes for ad-hoc testing (no longer needed for normal
user setup — see `nidusctl user create` below).
- `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl
-config config.yaml <calendar|addressbook> <share|unshare|shares> ...`)
for managing `internal/db` sharing grants. It's a thin argv-parsing
wrapper around `db.DB`'s methods — no server interaction, no daemon, no
RPC; it just opens the same SQLite file the running server uses. Since
the caldav/carddav backends query the shares tables on every request
(no caching), changes take effect immediately without restarting the
server.
-config config.yaml <user|calendar|addressbook> ...`) — the only way to
create/delete users, calendars, and address books, plus manage sharing
grants (`calendar|addressbook share|unshare|shares`). It's a thin
argv-parsing wrapper around `db.DB`'s methods (`tools/nidusctl/main.go`
routes subcommands, `tools/nidusctl/users.go` implements `user
create/delete/list/passwd` with interactive masked password prompting
via `golang.org/x/term`, falling back to a plain stdin read when not a
TTY) — no server interaction, no daemon, no RPC; it just opens the same
SQLite file the running server uses. Changes take effect immediately
without restarting the server since nothing is cached.
- `internal/web` — the web UI, mounted at `/web/` in `cmd/server/main.go`
(`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st,
dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own
routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the
outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s
Basic Auth: logins go through
`/web/login` (username/password checked against `cfg.Users` the same way
Basic Auth does, via bcrypt) and issue an opaque random session token
`/web/login` (username/password checked against the DB the same way
Basic Auth does, via `dbase.VerifyPassword`) and issue an opaque random session token
stored in the `web_sessions` SQLite table (`db.CreateSession`/
`SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an
`HttpOnly` cookie (`sessionCookieName` in `internal/web/session.go`).
@@ -138,7 +155,15 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
`internal/web/dashboard.go` renders the logged-in user's own
calendars/address books plus who they're shared with
(`SharesOfCalendar`/`SharesOfAddressBook`) and what's shared with them
(`CalendarsSharedWith`/`AddressBooksSharedWith`). `internal/web/shares.go`
(`CalendarsSharedWith`/`AddressBooksSharedWith`); `resourceCards(username)`
is the shared helper (also used by `internal/web/resources.go`, see
below) that builds the list of `templates.ResourceCard`s from the DB.
`internal/web/resources.go` handles POST (create) and DELETE (delete) at
`/web/resources/{calendar,addressbook}`, validating names against
`resourceNameRe` (`^[a-zA-Z0-9_-]{1,64}$`) and re-rendering the whole
`#resources` list (`templates.ResourceList`) since the *set* of cards
changes (unlike a share update, which only touches one card).
`internal/web/shares.go`
handles POST (create/update share) and DELETE (revoke) at
`/web/shares/{calendar,addressbook}`, re-rendering just the affected
resource card for htmx's `hx-swap="outerHTML"`; it always checks