Files
nidus/.github/copilot-instructions.md
T
arnefandCopilot b4644bc590 Fix WebDAV/CalDAV/CardDAV bugs, drop username from URLs, harden concurrency
- Root handler now only serves the welcome page for GET/HEAD; all other
  methods (e.g. OPTIONS, PROPFIND) return 405 with an Allow header instead
  of always returning 200, fixing client capability probes and PROPFIND
  misbehavior.
- Mount /files/ properly and cache one xwebdav.Handler per authenticated
  user so its LockSystem persists across requests instead of being
  recreated per-request (which broke LOCK/UNLOCK).
- Remove the username segment from all DAV URLs (/cal/, /card/, /files/
  are now identical for every account; the acting user is always resolved
  via Basic Auth, never the path).
- Reintroduce a fixed literal "home" path segment (/cal/home/,
  /card/home/) to preserve the URL segment depth that go-webdav's
  caldav/carddav server relies on to classify resources (principal vs.
  home-set vs. collection vs. object). Removing the username had
  collapsed this depth, silently misclassifying requests and returning
  empty <multistatus> responses (DAVx5 "no resources found").
- Replace the store's single global mutex with per-user sharded locks so
  different users' requests no longer serialize against each other.
- Add auth.NewContext test helper, WebDAV handler tests
  (per-user isolation, lock persistence across requests), and a
  concurrent multi-user store test.
- Update README and copilot-instructions to document the new URL scheme
  and the go-webdav path-depth classification quirk.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-18 12:49:57 +02:00

6.4 KiB

Copilot Instructions for nidus

A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a filesystem store. HTTP Basic Auth (bcrypt) with per-user isolated collections.

Build, test, lint

make build          # go build -o bin/davserver ./cmd/server
make run             # build + ./bin/davserver -config config.yaml
make test            # go test ./... -v -race
make lint            # golangci-lint run ./...
make tidy            # go mod tidy
go test ./internal/store/ -run TestStoreRoundTrip -v   # single test

There is currently only one test file: internal/store/store_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/, /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.
  • internal/auth — HTTP Basic Auth middleware (auth.Middleware.Wrap). Validates credentials against cfg.Users via 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, ...) if it's nil. auth.NewContext(ctx, p) is the test-only inverse, used to build authenticated contexts without a real Basic Auth handshake.
  • internal/store — the single source of truth for all persisted data. A thin filesystem KV abstraction: <data_dir>/<user>/<collection>/<objectID>. All collection/object names pass through sanitize() (via filepath.Base + strip ..) to prevent path traversal — preserve this when adding new store methods. Writes use temp-file + rename for atomicity (PutObject). Locking is sharded per-user (lockFor(user), a map[string]*sync.RWMutex guarded by its own mutex) rather than one global lock, so different users' requests don't serialize against each other.
  • internal/caldav and internal/carddav — implement the caldav.Backend/carddav.Backend interfaces from github.com/emersion/go-webdav on top of store.Store. Calendars are stored as collections prefixed cal-<name> and address books as card-<name> (see ListCalendars, parseCalPath). The URL scheme has no username segment, but DOES have a fixed literal home segment: /cal/ (principal), /cal/home/ (calendar-home-set), /cal/home/<calname>/ (calendar), /cal/home/<calname>/<objid> (object) — and equivalently /card/, /card/home/, /card/home/<bookname>/, /card/home/<bookname>/<objid> for carddav. These are identical for every user; the acting user always comes from auth.FromContext(ctx), never from the path. The home segment is load-bearing, not cosmetic: go-webdav's caldav/carddav server (in the github.com/emersion/go-webdav dependency, not our code) classifies each request purely by counting URL path segments relative to the handler's Prefix (which we leave "") — 1 segment = user principal, 2 = home-set, 3 = calendar/address book, 4 = object. If the segment counts don't line up (e.g. removing home would make /cal/ and /cal/<calname>/ collapse to 1 and 2 segments, misclassifying the calendar collection itself as the home-set), PROPFIND requests silently return an empty <multistatus> (200/207, zero <response> elements) — no error, just nothing found, which breaks client auto-discovery (e.g. DAVx5 reporting "no resources found"). Keep this in mind when touching parseCalPath/parseObjPath/parseBookPath, calHomePath/cardHomePath, or CurrentUserPrincipal — always preserve the exact segment depth at each level. Query methods (QueryCalendarObjects) currently list all objects and filter in-memory via caldav.Filter — fine for small collections, not optimized for scale.
  • 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 authenticated username (keyed off auth.FromContext), each rooted at <data_dir>/files/<username>/ on disk with its own persistent 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.

Conventions

  • No username in any DAV URL (/cal/, /card/, /files/ are the same for every account) — the acting user is always resolved from the Basic Auth identity (auth.FromContext), never parsed out of the request path. Don't reintroduce a <user> path segment when adding routes/paths.
  • Path parsing in the CalDAV/CardDAV backends assumes fixed URL segment positions (e.g. cal/home/<calname>/<objid>) split on / — see parseCalPath/parseObjPath/parseBookPath. The home segment must stay exactly one fixed literal segment (see note above on go-webdav's segment-count-based resource classification) — don't remove it or add/ remove segments elsewhere without re-checking all four resource-type depths still line up. New path-based operations should follow the same segment-index approach for consistency.
  • Store errors are sentinel values (store.ErrNotFound, store.ErrConflict) checked with errors.Is/direct comparison; backends translate them into webdav.NewHTTPError with the appropriate HTTP status.
  • 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 predates the nidus repo rename) — import paths still use this, not nidus.