chore(ci): migrate to Gitea Actions

This commit is contained in:
2026-09-02 21:53:14 +02:00
parent f34041d889
commit 5e3cc3513c
3 changed files with 35 additions and 249 deletions
-213
View File
@@ -1,213 +0,0 @@
# 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` and `db.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 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`). Holds only server/auth/
storage/logging/TLS settings — **no user, calendar, or address-book
data**; all of that lives in `internal/db` now (see below).
- `internal/auth` — HTTP Basic Auth middleware (`auth.Middleware.Wrap`).
Takes a `*db.DB` and validates credentials via `dbase.GetUser` +
`dbase.VerifyPassword` (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` (required — used for both
the base `ListCalendars`/`ListAddressBooks`/`Create*`/`Delete*`
operations and 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. Schema lives in `migrate()`; there's no
migration framework, just idempotent `CREATE 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`, and `CreateCalendar`/`DeleteCalendar`/`ListCalendars`,
`CreateAddressBook`/`DeleteAddressBook`/`ListAddressBooks`. `users` is
the parent table; `calendars`/`addressbooks` cascade-delete via FK on
`DeleteUser`; `calendar_shares`/`addressbook_shares`/`web_sessions`
reference usernames as plain strings (no FK) so `DeleteUser` explicitly
cleans those up in a transaction. `modernc.org/sqlite` has no typed
unique-constraint error, so `isUniqueConstraintErr()` string-matches the
driver's error message.
- `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 ad-hoc testing (no longer needed for normal
user setup — see `nidusctl user create` below).
- `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 around `db.DB`'s methods (`tools/nidusctl/main.go`
routes subcommands, `tools/nidusctl/users.go` implements `user
create/delete/list/passwd` with interactive masked password prompting
via `golang.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/` 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 the DB the same way
Basic Auth does, via `dbase.VerifyPassword`) 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`); `resourceCards(username)`
is the shared helper (also used by `internal/web/resources.go`, see
below) that builds the list of `templates.ResourceCard`s from the DB.
`internal/web/resources.go` handles POST (create) and DELETE (delete) at
`/web/resources/{calendar,addressbook}`, validating names against
`resourceNameRe` (`^[a-zA-Z0-9_-]{1,64}$`) and re-rendering the whole
`#resources` list (`templates.ResourceList`) since the *set* of cards
changes (unlike a share update, which only touches one card).
`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`.
+35 -36
View File
@@ -113,15 +113,15 @@ docker compose exec nidus nidusctl addressbook create alice contacts
This project uses year.month.hotfix versioning (e.g. `2026.8.0`, `2026.8.1`)
rather than semantic versioning. Pushing a version tag (e.g. `2026.8.0`) or
publishing a release triggers
[`.github/workflows/docker-release.yml`](.github/workflows/docker-release.yml),
[`.gitea/workflows/docker-release.yml`](.gitea/workflows/docker-release.yml),
which builds and publishes a multi-arch (`linux/amd64` + `linux/arm64`)
image to `git.arnef.de/arnef/nidus`, tagged with the version, `<year>.<month>`,
image to `git.arnef.de/arnef/nidus`, tagged with the version, `<major>.<minor>`,
`latest`, and the short commit SHA. It authenticates via the
`REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets.
A minimal standalone `docker-compose.yml` that pulls this image instead of
building from a checkout — just fetch `config.example.yaml`, copy it to
`config.yaml`, and adjust it to your needs:
building from a checkout — configuration is done entirely via environment
variables (no `config.yaml` to copy over):
```yaml
services:
@@ -182,24 +182,25 @@ grantee's own home-set alongside their own calendars — no separate account
or extra client configuration needed.
Sharing grants are stored in a small SQLite database at
`<data_dir>/nidus.db` (not in `config.yaml`) and can be managed either via
`<data_dir>/nidus.db` (not in any config file) and can be managed either via
the `nidusctl` CLI or the web UI's dashboard (see below):
```bash
# Give bob write access to alice's "work" calendar
go run ./tools/nidusctl -config config.yaml calendar share alice work bob write
go run ./tools/nidusctl calendar share alice work bob write
# List everyone alice's "work" calendar is shared with
go run ./tools/nidusctl -config config.yaml calendar shares alice work
go run ./tools/nidusctl calendar shares alice work
# Revoke access
go run ./tools/nidusctl -config config.yaml calendar unshare alice work bob
go run ./tools/nidusctl calendar unshare alice work bob
# Address books work the same way, using "addressbook" instead of "calendar"
go run ./tools/nidusctl -config config.yaml addressbook share alice contacts bob read
go run ./tools/nidusctl addressbook share alice contacts bob read
```
Or via `make`: `make nidusctl ARGS="calendar share alice work bob write"`.
(Both the server and `nidusctl` resolve the data directory from the
`NIDUS_DATA_DIR` environment variable.)
A calendar that `alice` shares with `bob` appears in bob's calendar
home-set as `/cal/home/alice~work/` (i.e. `<owner>~<calendar name>`) — the
@@ -225,18 +226,18 @@ mobile browsers:
place via [htmx](https://htmx.org/) without a full page reload.
- **Files** (`/web/files/`) — a browser for the same storage the WebDAV
endpoint (`/files/`) serves: navigate folders, create new folders,
upload files/folders (including via drag & drop), download, and delete
files or folders. Files open **inline** in the browser when the type
supports it (video, audio, images, PDF, …) instead of always forcing a
download; a separate "Download" action is always available to force a
save-as.
upload files/folders (including via drag & drop), download, sort by name or
date, and delete files or folders. Files open **inline** in the browser when
the type supports it (video, audio, images, PDF, …) instead of always
forcing a download; a separate "Download" action is always available to
force a save-as.
- **Contacts** (`/web/contacts/`) — browse address books, create/edit/
delete contacts (name, organization, birthday, phone numbers, emails,
addresses, photo), and import/export vCards (`.vcf`).
delete contacts (first/last name, organization, birthday, phone numbers,
emails, address, photo), and import/export vCards (`.vcf`).
- **Calendar** (`/web/calendar`) — month and week views across all your
own and shared calendars (including ICS/webcal subscriptions and the
Birthdays calendar), create/edit/delete events, per-calendar colors,
and import/export `.ics` files.
Birthdays calendar), with a detail view for each event; create/edit/delete
events, per-calendar colors, and import/export `.ics` files.
- **Account** (`/web/account`) — update your display name/email and
change your password.
- **Logout** (`/web/logout`).
@@ -287,7 +288,8 @@ Traefik) to terminate TLS and forward requests to the server.
## Managing users
All user/calendar/address-book management is done with `nidusctl` or the
web UI (`/web/`). Nothing is stored in `config.yaml` anymore.
web UI (`/web/`). User and resource data lives in `nidus.db`, not in a
config file.
```bash
# Users
@@ -314,15 +316,10 @@ nidusctl addressbook unshare <owner> <book> <user>
nidusctl addressbook shares <owner> <book>
```
Password are prompted for interactively (masked, double-entry) when
Passwords are prompted for interactively (masked, double-entry) when
`--password` is omitted. The web UI (`/web/`) also lets a logged-in user
create/delete their own calendars, address books, and ICS/webcal subscriptions
from the dashboard.
> **Upgrading from an older version?** The `users:` section in
> `config.yaml` is no longer read. Recreate your users with
> `nidusctl user create` (and their calendars/address books) — there is no
> automatic migration from the old config format.
create/delete their own calendars, address books, and ICS/webcal
subscriptions from the dashboard.
---
@@ -333,19 +330,22 @@ nidus/
├── cmd/server/ # main entrypoint
├── internal/
│ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints)
│ ├── caldav/ # CalDAV backend
│ ├── birthdays/ # compute a virtual Birthday calendar from contacts
│ ├── caldav/ # CalDAV backend (incl. ICS subscriptions & Birthdays)
│ ├── carddav/ # CardDAV backend
│ ├── config/ # YAML config loader
│ ├── config/ # configuration loader (environment variables)
│ ├── db/ # SQLite store (users, calendars, shares, sessions)
│ ├── icalfix/ # iCalendar (RFC 5545) parsing/fixing helpers
│ ├── icssub/ # remote ICS/webcal subscription fetcher
│ ├── store/ # filesystem storage layer
│ ├── web/ # web UI (templ, dashboard, share mgmt, sessions)
│ │ └── templates/ # templ templates (+ generated *_templ.go)
│ └── webdav/ # WebDAV file handler
├── internal/web/ # web UI (templ, dashboard, share mgmt, sessions)
│ └── templates/ # templ templates (+ generated *_templ.go)
├── cmd/nidusctl/ # admin CLI (users, calendars, address books, sharing)
├── tools/nidusctl/ # admin CLI (users, calendars, address books, sharing)
├── tools/migrate/ # data directory migration tool
├── tools/hashpwd/ # standalone bcrypt password generator
├── web/ # front-end assets: Tailwind input/config, static/
│ └── static/ # compiled app.css + htmx.min.js (embedded into the binary)
├── tools/migrate/ # data directory migration tool
├── config.example.yaml # sample configuration (copy to config.yaml)
├── Dockerfile
├── docker-compose.yaml
└── Makefile
@@ -370,4 +370,3 @@ the help of AI coding assistants (e.g. GitHub Copilot). Changes are
reviewed and tested where practical, but not every part of the codebase
has been fully reviewed yet — use accordingly, especially before relying
on this in security-sensitive environments.