- 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>
6.4 KiB
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 theslog.Logger, constructs thestore.Store, pre-creates each user's configured calendars/address-books as collections, wires upauth.Middleware, and builds thehttp.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 — gets405instead of a misleading200).internal/config— YAML config loading (config.Load), defaults (applyDefaults), and validation (validate).Config.Usersis amap[string]UserConfigkeyed by username; each user has a bcryptPassword,Calendars, andAddressBookslists that seed collection names.internal/auth— HTTP Basic Auth middleware (auth.Middleware.Wrap). Validates credentials againstcfg.Usersvia bcrypt, then stores a*Principal{Username, DisplayName, Email}in the request context. Downstream code retrieves it withauth.FromContext(ctx)— every backend method needs this and returnswebdav.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 throughsanitize()(viafilepath.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), amap[string]*sync.RWMutexguarded by its own mutex) rather than one global lock, so different users' requests don't serialize against each other.internal/caldavandinternal/carddav— implement thecaldav.Backend/carddav.Backendinterfaces fromgithub.com/emersion/go-webdavon top ofstore.Store. Calendars are stored as collections prefixedcal-<name>and address books ascard-<name>(seeListCalendars,parseCalPath). The URL scheme has no username segment, but DOES have a fixed literalhomesegment:/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 fromauth.FromContext(ctx), never from the path. Thehomesegment is load-bearing, not cosmetic: go-webdav'scaldav/carddavserver (in thegithub.com/emersion/go-webdavdependency, not our code) classifies each request purely by counting URL path segments relative to the handler'sPrefix(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. removinghomewould 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 touchingparseCalPath/parseObjPath/parseBookPath,calHomePath/cardHomePath, orCurrentUserPrincipal— always preserve the exact segment depth at each level. Query methods (QueryCalendarObjects) currently list all objects and filter in-memory viacaldav.Filter— fine for small collections, not optimized for scale.internal/webdav— plain-file WebDAV viagolang.org/x/net/webdav, mounted at the single fixed URL/files/for all users (no username in the path either).NewHandlercaches one*xwebdav.Handlerper authenticated username (keyed offauth.FromContext), each rooted at<data_dir>/files/<username>/on disk with its own persistentLockSystem— 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 forconfig.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/— seeparseCalPath/parseObjPath/parseBookPath. Thehomesegment 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 witherrors.Is/direct comparison; backends translate them intowebdav.NewHTTPErrorwith the appropriate HTTP status. - Logging uses
log/slogstructured 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 thenidusrepo rename) — import paths still use this, notnidus.