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

109 lines
6.4 KiB
Markdown

# 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
```bash
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`.