# Copilot Instructions for nidus A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a filesystem store, with calendar/address book sharing grants tracked in a small SQLite database. HTTP Basic Auth (bcrypt) with per-user isolated collections. A small server-rendered web UI (templ + Tailwind + htmx) at `/ui/` lets users log in and manage their shares. ## 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 make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ make web-css # templ-generate + rebuild web/static/app.css (needs `make web-deps` once, Node.js/npm) ``` Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, `internal/db/shares_test.go`, `internal/caldav/backend_test.go`, `internal/carddav/backend_test.go`, `internal/web/server_test.go`, `tools/nidusctl/main_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: `///`. 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-` and address books as `card-` (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//` (calendar), `/cal/home//` (object) — and equivalently `/card/`, `/card/home/`, `/card/home//`, `/card/home//` 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//` collapse to 1 and 2 segments, misclassifying the calendar collection itself as the home-set), PROPFIND requests silently return an empty `` (200/207, zero `` 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. **Sharing**: both backends take an `*db.DB` (may be `nil` to disable sharing). A calendar/address book shared with a user is exposed under the synthetic local name `~` (see `sharedNameSep`, `sharedCalendarName`/`sharedBookName`) in that user's own home-set — `resolveCalendar`/`resolveBook` split the local name back into owner+real name and check the grant's permission (`db.PermRead`/ `db.PermWrite`) via `dbase.CalendarShareFor`/`AddressBookShareFor` before allowing reads (any share) or writes (write share only). The shared data is never copied — it's read/written directly under the owner's own `store.Store` namespace, just addressed via the synthetic name from the grantee's requests. - `internal/db` — a small `database/sql` wrapper around `modernc.org/sqlite` (pure Go, no CGO) at `/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 migration framework, just idempotent `CREATE TABLE IF NOT EXISTS`. - `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 `/files//` 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 `) to generate bcrypt hashes for `config.yaml`. - `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl -config config.yaml ...`) 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. - `internal/web` — the web UI, mounted at `/ui/` in `cmd/server/main.go` (`web.NewServer(cfg, st, dbase, logger).Handler(webstatic.FS())`), entirely separate from `internal/auth`'s Basic Auth: logins go through `/ui/login` (username/password checked against `cfg.Users` the same way Basic Auth does, via bcrypt) 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`). `requireLogin` is the auth-guard middleware for authenticated routes, storing the username in the request context (`userFromContext`). `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` handles POST (create/update share) and DELETE (revoke) at `/ui/shares/{calendar,addressbook}`, re-rendering just the affected resource card for htmx's `hx-swap="outerHTML"`; it always checks `ownsResource` first so a user can only share resources actually configured for their own account (never someone else's, even via a forged form post). **htmx v2 quirk**: `hx-delete` requests send `hx-vals`/form params as URL **query string** parameters, not a request body (unlike POST/PUT/PATCH) — `handleShare` special-cases `r.Method == http.MethodDelete` to read from `r.URL.Query()` instead of calling `r.ParseForm()`. Templates live in `internal/web/templates/*.templ` (compiled to `*_templ.go` via `templ generate`/`make templ-generate` — regenerate after editing any `.templ` file, the generated files are committed). Styling is Tailwind v4, scanned directly over the generated `_templ.go` files (`web/input.css`'s `@source` directives) and compiled to `web/static/app.css` via `make web-css` (needs Node/npm — see `web/package.json`); htmx itself is vendored as a static file (`web/static/htmx.min.js`, not npm-installed) to avoid a CDN dependency. Both static assets are embedded into the Go binary at build time via `web/staticassets.go` (`//go:embed static`), so the compiled server has no runtime dependency on Node.js or the `web/` directory being present — Node/npm are only needed when actually changing templates/styles. ## 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 `` path segment when adding routes/paths. - Path parsing in the CalDAV/CardDAV backends assumes fixed URL segment positions (e.g. `cal/home//`) 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`.