- Add web-deps, nidusctl, and migrate commands to build section - Document ICS/webcal subscriptions and Birthdays calendar - Note Migrate() is idempotent - Add additional database tables - Include Docker/CI and database migration details
16 KiB
16 KiB
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
make build # go build -o bin/davserver ./cmd/server + ./bin/nidusctl
make run # build + ./bin/davserver -config config.yaml
make test # go test ./... -v -race
make lint # golangci-lint run ./... (no config file; uses defaults)
make tidy # go mod tidy
make web-deps # install Tailwind CLI + TypeScript (needed once, or after web/package.json changes)
make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ
make web-css # templ-generate + rebuild web/static/app.css
make web-ts # compile web/ts/*.ts to web/static/*.js
make web-assets # web-css + web-ts (everything under web/static/)
make nidusctl # build + run nidusctl with ARGS (e.g. `make nidusctl ARGS="user list"`)
go run ./tools/migrate -config config.yaml # restructure data directory
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 theslog.Logger, constructs thestore.Storeanddb.DB, lists all users/calendars/ address-books from the DB (dbase.ListUsers/ListCalendars/ListAddressBooks) to pre-create their collections on disk, warns if zero users exist (nidusctl user create ...), 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). Holds only server/auth/ storage/logging/TLS settings — no user, calendar, or address-book data; all of that lives ininternal/dbnow (see below).internal/auth— HTTP Basic Auth middleware (auth.Middleware.Wrap). Takes a*db.DBand validates credentials viadbase.GetUser+dbase.VerifyPassword(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>/[calendars|addressbooks|files]/<name>. 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.Migrate()restructures data from the old format (cal-<name>,card-<name>) to the new unified layout and is idempotent.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. Sharing: both backends take an*db.DB(required — used for both the baseListCalendars/ListAddressBooks/Create*/Delete*operations and sharing). A calendar/address book shared with a user is exposed under the synthetic local name<owner>~<name>(seesharedNameSep,sharedCalendarName/sharedBookName) in that user's own home-set —resolveCalendar/resolveBooksplit the local name back into owner+real name and check the grant's permission (db.PermRead/db.PermWrite) viadbase.CalendarShareFor/AddressBookShareForbefore allowing reads (any share) or writes (write share only). The shared data is never copied — it's read/written directly under the owner's ownstore.Storenamespace, just addressed via the synthetic name from the grantee's requests. ICSSubscriptions: read-only calendars backed by remote ICS/webcal URLs (seedb.CreateICSSubscription,internal/db/ics.go) are exposed alongside real calendars under/cal/home/<name>/(shared namespace; seeinternal/caldav/ics.go).Birthdaysis a computed calendar synthesized from contacts' BDAY fields (seeinternal/caldav/birthdays.go,internal/birthdays/birthdays.go).internal/db— a smalldatabase/sqlwrapper aroundmodernc.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. Schema lives inmigrate(); there's no migration framework, just idempotentCREATE TABLE IF NOT EXISTS. Foreign keys are enabled per-connection (?_pragma=foreign_keys(1)in the DSN). This is now the single source of truth for users, calendars, and address books (internal/db/users.go):CreateUser/SetPassword/DeleteUser/GetUser/VerifyPassword/ListUsers, andCreateCalendar/DeleteCalendar/ListCalendars,CreateAddressBook/DeleteAddressBook/ListAddressBooks.usersis the parent table;calendars/addressbookscascade-delete via FK onDeleteUser;calendar_shares/addressbook_shares/web_sessionsreference usernames as plain strings (no FK) soDeleteUserexplicitly cleans those up in a transaction.modernc.org/sqlitehas no typed unique-constraint error, soisUniqueConstraintErr()string-matches the driver's error message. Additional tables:birthday_calendars(per-user display color for the computed Birthdays calendar),ics_subscriptions(remote ICS/webcal calendar subscriptions),web_sessions(server-side session tokens).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 for ad-hoc testing (no longer needed for normal user setup — seenidusctl user createbelow).tools/migrate— restructures data directory from old format (cal-<name>,card-<name>) to unified layout (calendars/,addressbooks/,files/). Run automatically on server startup; invoke manually withgo run ./tools/migrate -config config.yaml.tools/nidusctl— standalone admin CLI (go run ./tools/nidusctl -config config.yaml <user|calendar|addressbook> ...) — the only way to create/delete users, calendars, and address books, plus manage sharing grants (calendar|addressbook share|unshare|shares). It's a thin argv-parsing wrapper arounddb.DB's methods (tools/nidusctl/main.goroutes subcommands,tools/nidusctl/users.goimplementsuser create/delete/list/passwdwith interactive masked password prompting viagolang.org/x/term, falling back to a plain stdin read when not a TTY) — no server interaction, no daemon, no RPC; it just opens the same SQLite file the running server uses. Changes take effect immediately without restarting the server since nothing is cached.internal/web— the web UI, mounted at/web/incmd/server/main.go(mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st, dbase, logger).Handler(webstatic.FS()))), soServer.Handler's own routes are all unprefixed —/login,/,/shares/...— and only the outer mux adds the/webprefix), entirely separate frominternal/auth's Basic Auth: logins go through/web/login(username/password checked against the DB the same way Basic Auth does, viadbase.VerifyPassword) and issue an opaque random session token stored in theweb_sessionsSQLite table (db.CreateSession/SessionUser/DeleteSession, seeinternal/db/sessions.go), set as anHttpOnlycookie (sessionCookieNameininternal/web/session.go).requireLoginis the auth-guard middleware for authenticated routes, storing the username in the request context (userFromContext).internal/web/dashboard.gorenders 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);resourceCards(username)is the shared helper (also used byinternal/web/resources.go, see below) that builds the list oftemplates.ResourceCards from the DB.internal/web/resources.gohandles POST (create) and DELETE (delete) at/web/resources/{calendar,addressbook}, validating names againstresourceNameRe(^[a-zA-Z0-9_-]{1,64}$) and re-rendering the whole#resourceslist (templates.ResourceList) since the set of cards changes (unlike a share update, which only touches one card).internal/web/shares.gohandles POST (create/update share) and DELETE (revoke) at/web/shares/{calendar,addressbook}, re-rendering just the affected resource card for htmx'shx-swap="outerHTML"; it always checksownsResourcefirst 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-deleterequests sendhx-vals/form params as URL query string parameters, not a request body (unlike POST/PUT/PATCH) —handleSharespecial-casesr.Method == http.MethodDeleteto read fromr.URL.Query()instead of callingr.ParseForm(). Templates live ininternal/web/templates/*.templ(compiled to*_templ.goviatempl generate/make templ-generate— regenerate after editing any.templfile, the generated files are committed). Styling is Tailwind v4, scanned directly over the generated_templ.gofiles (web/input.css's@sourcedirectives) and compiled toweb/static/app.cssviamake web-css(needs Node/npm — seeweb/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 underweb/ts/*.ts, compiled to plain JS viatsc(web/tsconfig.json,make web-ts) intoweb/static/*.jsas ES modules (<script type="module">). All static assets (CSS, JS, vendored htmx) are embedded into the Go binary at build time viaweb/staticassets.go(//go:embed static), so the compiled server has no runtime dependency on Node.js or theweb/directory being present — Node/npm are only needed when actually changing templates/styles/TS (make web-assetsrebuilds everything underweb/static/). Web UI features: dashboard (calendars/address books/ICS subscriptions management), login (cookie-based sessions), share management (create/update/ revoke share grants via htmx), files browser (inline preview, upload, download), contacts manager (vCard import/export, edit fields), calendar view (month/week, per-calendar colors), account settings (name/email/password). The syntheticBirthdayscalendar (computed from contacts' BDAY fields) and ICS/webcal subscriptions are managed via the web UI just like real calendars.
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. - Docker/CI: Docker image pushed to
git.arnef.de/arnef/niduson tags (v*) or releases (.github/workflows/docker-release.yml), multi-arch (linux/amd64,linux/arm64). Usedocker-composefor local dev with healthcheck on/healthz. - Database schema lives in
internal/db/db.gomigrate()— all tables are created withCREATE TABLE IF NOT EXISTSand schema changes are applied viaALTER TABLEinmigrateAddColumns(). No external migration framework.