- web/ts/login.ts: small vanilla TS module toggling the password input's type between 'password'/'text' via a 'Show/Hide' button, so users can rule out typos before submitting. Compiled to web/static/login.js (ES module) via tsc (web/tsconfig.json, new 'make web-ts'/'web-assets' Makefile targets) and loaded via <script type="module">. Compiled JS is committed/embedded the same way as the compiled CSS — no Node.js needed at runtime. - Verified end-to-end against the live proxied server (https://local.unqr.dev/web/login): POST /web/login correctly returns 303 + Set-Cookie, and the session then authorizes GET /web/ — the server-side login flow is confirmed working correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
189 lines
12 KiB
Markdown
189 lines
12 KiB
Markdown
# 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
|
|
`/web/` 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)
|
|
make web-ts # compile web/ts/*.ts to web/static/*.js
|
|
make web-assets # web-css + web-ts (everything under web/static/)
|
|
```
|
|
|
|
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: `<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.
|
|
**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 `<owner>~<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 `<data_dir>/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
|
|
`<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`.
|
|
- `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl
|
|
-config config.yaml <calendar|addressbook> <share|unshare|shares> ...`)
|
|
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 `/web/` in `cmd/server/main.go`
|
|
(`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st,
|
|
dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own
|
|
routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the
|
|
outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s
|
|
Basic Auth: logins go through
|
|
`/web/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
|
|
`/web/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.
|
|
Client-side-only logic (currently just the login page's password-visibility
|
|
toggle) is written in TypeScript under `web/ts/*.ts`, compiled to plain
|
|
JS via `tsc` (`web/tsconfig.json`, `make web-ts`) into `web/static/*.js`
|
|
as ES modules (`<script type="module">`). All static assets (CSS, JS,
|
|
vendored htmx) 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/TS
|
|
(`make web-assets` rebuilds everything under `web/static/`).
|
|
|
|
## 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`.
|