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>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user