14 Commits
Author SHA1 Message Date
arnef e8c4dadfea chore: fmt code 2026-08-30 19:58:06 +02:00
arnef 2e43e32a9f refactor: clean up nidusctl CLI 2026-08-30 19:54:34 +02:00
arnef 9b9cbe2f6f refactor: update migration logic 2026-08-30 19:54:34 +02:00
arnef 0ee0874458 refactor: streamline session management 2026-08-30 19:54:34 +02:00
arnef b364bee265 refactor: simplify config validation 2026-08-30 19:54:34 +02:00
arnef dd77a61b18 refactor: unify config and data structures 2026-08-30 19:54:34 +02:00
arnef d3780e9e32 refactor: align import paths with module name 2026-08-30 18:51:45 +02:00
arnef e96a0b8c92 Merge pull request 'feat: implement unified directory structure with automatic migration for CalDAV and CardDAV' (#4) from bug/3 into main
Reviewed-on: #4

closes #3
2026-08-30 12:04:12 +00:00
arnef 0567fea729 docs: remove CHANGES_SUMMARY.md (superseded by AGENTS.md) 2026-08-30 13:57:16 +02:00
arnef 5af3a8508b docs: update AGENTS.md with current commands and features
- 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
2026-08-30 13:54:55 +02:00
arnef 8d2d8d3972 fix: update web files root to use unified path structure 2026-08-30 13:04:40 +02:00
arnef 2fd39c8180 refactor: unify data directory structure
Unify user data directory from fragmented layout to consistent nested format:

- Move WebDAV from: data/files/<username> → data/<username>/files/
- Move CalDAV from: data/<username>/cal-<name> → data/<username>/calendars/<name>
- Move CardDAV from: data/<username>/card-<name> → data/<username>/addressbooks/<name>

Changes:
- internal/store/store.go: Update collectionPath() to map collection names
- internal/store/migrate.go: Add idempotent Migrate() method
- internal/store/migrate_test.go: Comprehensive migration tests
- internal/webdav/handler.go: Use new unified path structure
- cmd/server/main.go: Auto-run migration on startup
- tools/nidusctl/main.go: Add migrate subcommand
- Update tests to verify new structure

URL endpoints unchanged - only on-disk structure modified. All tests pass.
2026-08-30 12:15:31 +02:00
arnefandCopilot f463c01f0f Add a standalone docker-compose.yml example using the pre-built image
Complements the existing "build: ." example (for a full checkout) with
a minimal compose file that just pulls git.arnef.de/arnef/nidus:latest,
for users who only want to run the container without cloning the repo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 22:00:20 +02:00
arnefandCopilot cd0761c9d1 Remove TLS/Reverse proxy and Dependencies sections from README
Both were low-value docs (TLS config is already in the Configuration
reference table; Go/JS dependencies are visible in go.mod and
web/package.json) that just added noise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 21:59:05 +02:00
41 changed files with 1032 additions and 432 deletions
+239
View File
@@ -0,0 +1,239 @@
# 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 + ./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 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>/[calendars|addressbooks|files]/<name>`.
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. `Migrate()` restructures data from the old format (`cal-<name>`,
`card-<name>`) to the new unified layout and is idempotent.
- `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.
**ICSSubscriptions**: read-only calendars backed by remote ICS/webcal
URLs (see `db.CreateICSSubscription`, `internal/db/ics.go`) are exposed
alongside real calendars under `/cal/home/<name>/` (shared namespace;
see `internal/caldav/ics.go`). `Birthdays` is a computed calendar
synthesized from contacts' BDAY fields (see `internal/caldav/birthdays.go`,
`internal/birthdays/birthdays.go`).
- `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.
**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 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/migrate` — restructures data directory from old format (`cal-<name>`,
`card-<name>`) to unified layout (`calendars/`, `addressbooks/`,
`files/`). Run automatically on server startup; invoke manually with
`go 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 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/`).
**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
synthetic `Birthdays` calendar (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 `/` — 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`.
- **Docker/CI**: Docker image pushed to `git.arnef.de/arnef/nidus` on tags (`v*`) or releases (`.github/workflows/docker-release.yml`), multi-arch (`linux/amd64`, `linux/arm64`). Use `docker-compose` for local dev with healthcheck on `/healthz`.
- **Database schema** lives in `internal/db/db.go` `migrate()` — all tables are created with `CREATE TABLE IF NOT EXISTS` and schema changes are applied via `ALTER TABLE` in `migrateAddColumns()`. No external migration framework.
+9 -6
View File
@@ -18,13 +18,16 @@ WORKDIR /app
COPY --from=builder /bin/davserver /usr/local/bin/davserver
COPY --from=builder /bin/nidusctl /usr/local/bin/nidusctl
# Default data and config locations
# Default data location
VOLUME ["/app/data"]
# config.yaml itself is git-ignored (it holds real secrets), so the image
# ships the example config as a working default; mount your own
# config.yaml over /app/config.yaml (see docker-compose.yaml) to override it.
COPY config.example.yaml /app/config.yaml
# environment variables (override as needed)
ENV NIDUS_DATA_DIR=/app/data
ENV NIDUS_HOST=0.0.0.0
ENV NIDUS_PORT=8080
ENV NIDUS_LOG_LEVEL=warn
ENV NIDUS_LOG_FORMAT=text
EXPOSE 8080
ENTRYPOINT ["davserver", "-config", "/app/config.yaml"]
ENTRYPOINT ["davserver"]
+2 -2
View File
@@ -7,7 +7,7 @@ build:
## run: run the server locally
run: build
./bin/davserver -config config.yaml
./bin/davserver
## test: run all tests
test:
@@ -37,7 +37,7 @@ hash-password:
## nidusctl: build and run the sharing-grant admin CLI
## Usage: make nidusctl ARGS="calendar share alice work bob write"
nidusctl: build
./bin/nidusctl -config config.yaml $(ARGS)
./bin/nidusctl $(ARGS)
## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ
templ-generate:
+105 -127
View File
@@ -1,4 +1,4 @@
# DAV Server
# nidus
A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go.
@@ -23,56 +23,61 @@ and files, all kept under your own roof instead of a third-party cloud.
- **Web UI** — a mobile-friendly app at `/web/` for managing calendars,
contacts, files, and account settings (see [Web UI](#web-ui) below),
built with templ + Tailwind + htmx
- **ICSSubscriptions** — add remote ICS/webcal calendars
- **Birthdays calendar** — auto-computed from contacts' BDAY fields
- Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav`
- Optional **TLS** (or use a reverse proxy)
- Structured logging (text or JSON)
- Graceful shutdown
- Docker & Docker Compose support
## Quick start
### 1. Install dependencies
```bash
go mod tidy
```
### 2. Create your `config.yaml`
Copy the example config and edit it — `config.yaml` is git-ignored so your
real settings never get committed:
```bash
cp config.example.yaml config.yaml
```
Users, calendars, and address books are **no longer configured in
`config.yaml`** — they live in the SQLite database and are managed with
`nidusctl` (see below).
### 3. Run the server
### 1. Run the server
```bash
make run
# or
go run ./cmd/server -config config.yaml
go run ./cmd/server
```
The server starts at **http://localhost:8080**.
### 4. Create a user and their resources
### 2. Configure the server via environment variables
The server is configured via environment variables:
```bash
go run ./tools/nidusctl -config config.yaml user create alice \
# Required: Set the data directory
export NIDUS_DATA_DIR="./data"
# Optional: Set port, host, and base URL
export NIDUS_PORT="8080"
export NIDUS_HOST="0.0.0.0"
export NIDUS_BASE_URL="https://dav.example.com"
# Optional: Set auth realm
export NIDUS_AUTH_REALM="My DAV Server"
# Optional: Set logging
export NIDUS_LOG_LEVEL="info"
export NIDUS_LOG_FORMAT="text"
```
This server does **not** handle TLS — use a reverse proxy (e.g. Nginx, Caddy,
Traefik) to terminate TLS and forward requests to the server.
### 3. Create a user and their resources
```bash
go run ./tools/nidusctl user create alice \
--display-name "Alice Smith" --email alice@example.com
# (prompts for a password; use --password to skip the prompt, e.g. in scripts)
go run ./tools/nidusctl -config config.yaml calendar create alice personal
go run ./tools/nidusctl -config config.yaml addressbook create alice contacts
go run ./tools/nidusctl calendar create alice personal
go run ./tools/nidusctl addressbook create alice contacts
```
Users can also be created/removed via the web UI (`/web/`) once logged in
as an existing user — see **Web UI** below.
Or use the web UI (`/web/`) once logged in — see **Web UI** below.
---
@@ -83,44 +88,64 @@ as an existing user — see **Web UI** below.
docker compose up --build
# Or build manually
docker build -t davserver .
docker build -t nidus .
docker run -p 8080:8080 \
-v ./config.yaml:/app/config.yaml:ro \
-v dav-data:/app/data \
davserver
-v nidus-data:/app/data \
-e NIDUS_DATA_DIR=/app/data \
nidus
```
The image also ships `nidusctl`, so once the container is running you can
create your first user (and their calendars/address books) with
`docker compose exec` — no need to install Go locally:
`docker compose exec`:
```bash
docker compose exec davserver nidusctl -config /app/config.yaml user create alice \
docker compose exec nidus nidusctl user create alice \
--display-name "Alice Smith" --email alice@example.com
# (prompts for a password; use --password to skip the prompt, e.g. in scripts)
# (prompts for a password; use --password to skip the prompt)
docker compose exec davserver nidusctl -config /app/config.yaml calendar create alice personal
docker compose exec davserver nidusctl -config /app/config.yaml addressbook create alice contacts
docker compose exec nidus nidusctl calendar create alice personal
docker compose exec nidus nidusctl addressbook create alice contacts
```
### Pre-built images
### Using pre-built images
Pushing a version tag (e.g. `v1.2.3`) or publishing a release triggers
[`.github/workflows/docker-release.yml`](.github/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, `<major>.<minor>`,
`latest`, and the short commit SHA. It authenticates via the
`REGISTRY_USERNAME`/`REGISTRY_PASSWORD` repository secrets. Point
`docker-compose.yaml`'s `image:` at it instead of `build: .` to use it
directly, e.g.:
`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:
```yaml
services:
davserver:
nidus:
image: git.arnef.de/arnef/nidus:latest
ports:
- "8080:8080"
volumes:
- nidus-data:/app/data
environment:
- NIDUS_DATA_DIR=/app/data
# Optional: other environment variables
# - NIDUS_PORT=8080
# - NIDUS_HOST=0.0.0.0
# - NIDUS_BASE_URL=https://dav.example.com
# - NIDUS_AUTH_REALM="My DAV Server"
# - NIDUS_LOG_LEVEL=info
# - NIDUS_LOG_FORMAT=text
restart: unless-stopped
volumes:
nidus-data:
```
---
Note: This server does **not** handle TLS. Use a reverse proxy (e.g. Nginx,
Caddy, Traefik) to terminate TLS and forward requests to the server.
## API endpoints
@@ -192,10 +217,10 @@ mobile browsers:
`nidus.db` (`web_sessions` table), independent of DAV Basic Auth. Includes
a "Show/Hide" password toggle to rule out typos before submitting.
- **Dashboard** (`/web/`) — create/delete your own calendars, address
books, and ICS/webcal subscriptions; see who your resources are shared
with and what others have shared with you; manage sharing grants
directly (same effect as `nidusctl`) — updates happen in place via
[htmx](https://htmx.org/) without a full page reload.
books, ICS/webcal subscriptions, and the Birthdays calendar; see who your
resources are shared with and what others have shared with you; manage
sharing grants directly (same effect as `nidusctl`) — updates happen in
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
@@ -207,8 +232,9 @@ mobile browsers:
delete contacts (name, organization, birthday, phone numbers, emails,
addresses, photo), and import/export vCards (`.vcf`).
- **Calendar** (`/web/calendar`) — month and week views across all your
own and shared calendars, create/edit/delete events, per-calendar
colors, and import/export `.ics` files.
own and shared calendars (including ICS/webcal subscriptions and the
Birthdays calendar), 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`).
@@ -230,64 +256,36 @@ make web-assets # regenerate templ code + rebuild web/static/app.css and web/st
---
## TLS / Reverse proxy
### Self-signed certificate (development)
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
```
Update `config.yaml`:
```yaml
tls:
enabled: true
cert_file: cert.pem
key_file: key.pem
```
### Caddy reverse proxy (recommended for production)
```
dav.example.com {
reverse_proxy localhost:8080
}
```
---
## Configuration reference
```yaml
server:
host: "0.0.0.0"
port: 8080
base_url: "https://dav.example.com" # used in DAV responses
Configuration is done via environment variables:
auth:
realm: "My DAV Server"
| Variable | Default | Description |
|----------|---------|-------------|
| `NIDUS_HOST` | `0.0.0.0` | Server listen host |
| `NIDUS_PORT` | `8080` | Server listen port |
| `NIDUS_BASE_URL` | (auto) | Public URL for DAV responses (e.g. https://dav.example.com) |
| `NIDUS_AUTH_REALM` | `DAV Server` | HTTP Basic Auth realm |
| `NIDUS_DATA_DIR` | `./data` | Data directory for all user data |
| `NIDUS_LOG_LEVEL` | `info` | Log level: debug, info, warn, error |
| `NIDUS_LOG_FORMAT` | `text` | Log format: text, json |
storage:
data_dir: "./data" # all user data lives here
Example:
logging:
level: "info" # debug | info | warn | error
format: "text" # text | json
tls:
enabled: false
cert_file: ""
key_file: ""
```bash
export NIDUS_DATA_DIR="./data"
export NIDUS_PORT="8080"
export NIDUS_BASE_URL="https://dav.example.com"
export NIDUS_LOG_LEVEL="info"
```
Users, calendars, and address books are managed via `nidusctl`, not
`config.yaml` — see **Managing users** below.
This server does **not** handle TLS — use a reverse proxy (e.g. Nginx, Caddy,
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). Nothing is stored in `config.yaml` anymore.
All user/calendar/address-book management is done with `nidusctl` or the
web UI (`/web/`). Nothing is stored in `config.yaml` anymore.
```bash
# Users
@@ -314,9 +312,10 @@ nidusctl addressbook unshare <owner> <book> <user>
nidusctl addressbook shares <owner> <book>
```
Passwords are prompted for interactively (masked, double-entry) when
Password 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 and address books from the dashboard.
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
@@ -328,22 +327,22 @@ create/delete their own calendars and address books from the dashboard.
## Project layout
```
caldav-server/
nidus/
├── cmd/server/ # main entrypoint
├── internal/
│ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints)
│ ├── caldav/ # CalDAV backend
│ ├── carddav/ # CardDAV backend
│ ├── config/ # YAML config loader
│ ├── db/ # SQLite store (shares, web UI sessions)
│ ├── db/ # SQLite store (users, calendars, shares, sessions)
│ ├── store/ # filesystem storage layer
│ ├── web/ # web UI (cookie sessions, dashboard, share mgmt)
│ │ └── templates/ # templ templates (+ generated *_templ.go)
│ └── webdav/ # WebDAV file handler
├── tools/hashpwd/ # bcrypt password hasher CLI
├── tools/nidusctl/ # sharing-grant admin CLI
├── internal/web/ # web UI (templ, dashboard, share mgmt, sessions)
│ └── templates/ # templ templates (+ generated *_templ.go)
├── cmd/nidusctl/ # admin CLI (users, calendars, address books, sharing)
├── 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
@@ -370,24 +369,3 @@ 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.
---
## Dependencies
| Package | Purpose |
|---------|---------|
| `github.com/emersion/go-webdav` | WebDAV/CalDAV/CardDAV protocol layer |
| `github.com/emersion/go-ical` | iCalendar parsing/serialisation |
| `github.com/emersion/go-vcard` | vCard parsing/serialisation |
| `golang.org/x/crypto` | bcrypt |
| `golang.org/x/net` | `golang.org/x/net/webdav` |
| `gopkg.in/yaml.v3` | YAML config parsing |
| `modernc.org/sqlite` | Pure-Go SQLite driver (shares, web UI sessions) |
| `github.com/a-h/templ` | Type-safe Go HTML templates (web UI) |
Front-end (dev-only, not required at runtime — see [Web UI](#web-ui)):
| Tool | Purpose |
|------|---------|
| Tailwind CSS v4 (`web/package.json`) | Utility-first CSS, compiled to `web/static/app.css` |
| [htmx](https://htmx.org/) (`web/static/htmx.min.js`, vendored) | Partial page updates without a JS framework |
+19 -22
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net"
@@ -13,24 +12,20 @@ import (
"syscall"
"time"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/caldav"
"github.com/yourusername/caldav-server/internal/carddav"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
"github.com/yourusername/caldav-server/internal/web"
filewebdav "github.com/yourusername/caldav-server/internal/webdav"
webstatic "github.com/yourusername/caldav-server/web"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/caldav"
"git.arnef.de/arnef/nidus/internal/carddav"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web"
filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
webstatic "git.arnef.de/arnef/nidus/web"
)
func main() {
var cfgPath string
flag.StringVar(&cfgPath, "config", "config.yaml", "path to configuration file")
flag.Parse()
// ---- Configuration ----
cfg, err := config.Load(cfgPath)
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
os.Exit(1)
@@ -41,8 +36,7 @@ func main() {
logger.Info("starting DAV server",
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"base_url", cfg.Server.BaseURL,
"tls", cfg.TLS.Enabled)
"base_url", cfg.Server.BaseURL)
// ---- Storage ----
st, err := store.NewStore(cfg.Storage.DataDir)
@@ -51,6 +45,13 @@ func main() {
os.Exit(1)
}
// ---- Run migration if data directory needs restructuring ----
if err := st.Migrate(); err != nil {
logger.Error("migration failed", "error", err)
os.Exit(1)
}
logger.Info("data directory migration completed")
// ---- Database (users, calendars, address books, sharing) ----
dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db")
dbase, err := db.Open(dbPath)
@@ -136,11 +137,7 @@ func main() {
}()
logger.Info("server ready", "addr", addr)
if cfg.TLS.Enabled {
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
} else {
err = srv.ListenAndServe()
}
err = srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
+28 -29
View File
@@ -1,31 +1,30 @@
server:
host: "0.0.0.0"
port: 8080
# Set this to your public-facing URL so discovery responses are correct.
# base_url: "https://dav.example.com"
auth:
realm: "My DAV Server"
storage:
data_dir: "./data"
logging:
level: "debug" # debug | info | warn | error
format: "text" # text | json
tls:
enabled: false
# cert_file: "/etc/ssl/certs/dav.crt"
# key_file: "/etc/ssl/private/dav.key"
# Users, calendars, and address books are no longer configured here — they
# are stored in the database (<data_dir>/nidus.db) and managed with
# nidusctl or the web UI (/web/):
# nidus configuration via environment variables
#
# nidusctl user create alice --password mysecretpassword --display-name "Alice Smith" --email alice@example.com
# nidusctl calendar create alice personal
# nidusctl calendar create alice work
# nidusctl addressbook create alice contacts
# Environment variables override any defaults shown below.
# Set them before starting the server.
#
# Run `nidusctl help` for the full command list.
# Server configuration:
# NIDUS_HOST - listen host (default: 0.0.0.0)
# NIDUS_PORT - listen port (default: 8080)
# NIDUS_BASE_URL - public URL for DAV responses (default: auto-generated from host:port)
#
# Authentication:
# NIDUS_AUTH_REALM - HTTP Basic Auth realm (default: "DAV Server")
#
# Storage:
# NIDUS_DATA_DIR - data directory (default: ./data)
#
# Logging:
# NIDUS_LOG_LEVEL - log level: debug, info, warn, error (default: info)
# NIDUS_LOG_FORMAT - log format: text, json (default: text)
# Example (uncomment and modify as needed):
# export NIDUS_HOST="0.0.0.0"
# export NIDUS_PORT="8080"
# export NIDUS_BASE_URL="https://dav.example.com"
# export NIDUS_DATA_DIR="./data"
# export NIDUS_LOG_LEVEL="info"
# export NIDUS_LOG_FORMAT="text"
# Note: The server does not handle TLS - use a reverse proxy (e.g. Nginx, Caddy,
# Traefik) to terminate TLS and forward requests to the server.
+12 -4
View File
@@ -1,11 +1,19 @@
services:
davserver:
nidus:
build: .
ports:
- "8080:8080"
environment:
# Optional: other environment variables
# - NIDUS_DATA_DIR=/app/data
# - NIDUS_HOST=0.0.0.0
# - NIDUS_PORT=8080
# - NIDUS_BASE_URL=https://dav.example.com
# - NIDUS_AUTH_REALM="My DAV Server"
# - NIDUS_LOG_LEVEL=info
# - NIDUS_LOG_FORMAT=text
volumes:
- ./config.yaml:/app/config.yaml:ro
- dav-data:/app/data
- nidus-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
@@ -14,4 +22,4 @@ services:
retries: 3
volumes:
dav-data:
nidus-data:
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/yourusername/caldav-server
module git.arnef.de/arnef/nidus
go 1.25.0
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"log/slog"
"net/http"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
)
type contextKey string
+2 -2
View File
@@ -14,8 +14,8 @@ import (
"github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
)
// Contact holds one contact's parsed birthday.
+6 -6
View File
@@ -14,15 +14,15 @@ import (
"strings"
"time"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/icssub"
"github.com/yourusername/caldav-server/internal/store"
)
// sharedNameSep separates the owner from the calendar name in the
+4 -4
View File
@@ -11,11 +11,11 @@ import (
"strings"
"testing"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
)
func newTestBackend(t *testing.T) (*Backend, *db.DB) {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/birthdays"
)
// birthdaysCalendarName is the fixed, reserved local calendar name the
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/db"
"git.arnef.de/arnef/nidus/internal/db"
)
// defaultICSColor is the display color for an ICS/webcal subscription
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"strings"
"time"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/carddav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
)
// sharedNameSep separates the owner from the address book name in the
+4 -4
View File
@@ -8,11 +8,11 @@ import (
"strings"
"testing"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
)
func newTestBackend(t *testing.T) (*Backend, *db.DB) {
+37 -64
View File
@@ -3,97 +3,70 @@ package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
"strconv"
)
// Config is the top-level server configuration.
type Config struct {
Server ServerConfig `yaml:"server"`
Auth AuthConfig `yaml:"auth"`
Storage StorageConfig `yaml:"storage"`
TLS TLSConfig `yaml:"tls"`
Logging LoggingConfig `yaml:"logging"`
Server ServerConfig
Auth AuthConfig
Storage StorageConfig
Logging LoggingConfig
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
// Base URL used in DAV responses (e.g. https://dav.example.com)
BaseURL string `yaml:"base_url"`
Host string
Port int
BaseURL string
}
type AuthConfig struct {
// Realm shown in WWW-Authenticate header
Realm string `yaml:"realm"`
Realm string
}
type StorageConfig struct {
// Root directory for all data
DataDir string `yaml:"data_dir"`
}
type TLSConfig struct {
Enabled bool `yaml:"enabled"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
DataDir string
}
type LoggingConfig struct {
Level string `yaml:"level"` // debug | info | warn | error
Format string `yaml:"format"` // text | json
Level string
Format string
}
// Load reads and parses a YAML config file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %q: %w", path, err)
}
// Load reads and parses environment variables to create the configuration.
func Load() (*Config, error) {
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %q: %w", path, err)
}
cfg.applyDefaults()
return cfg, cfg.validate()
return cfg, nil
}
func (c *Config) applyDefaults() {
if c.Server.Host == "" {
c.Server.Host = "0.0.0.0"
}
if c.Server.Port == 0 {
c.Server.Port = 8080
}
c.Server.Host = getEnv("NIDUS_HOST", "0.0.0.0")
c.Server.Port = getEnvInt("NIDUS_PORT", 8080)
c.Server.BaseURL = getEnv("NIDUS_BASE_URL", "")
if c.Server.BaseURL == "" {
scheme := "http"
if c.TLS.Enabled {
scheme = "https"
}
c.Server.BaseURL = fmt.Sprintf("%s://%s:%d", scheme, c.Server.Host, c.Server.Port)
}
if c.Auth.Realm == "" {
c.Auth.Realm = "DAV Server"
}
if c.Storage.DataDir == "" {
c.Storage.DataDir = "./data"
}
if c.Logging.Level == "" {
c.Logging.Level = "info"
}
if c.Logging.Format == "" {
c.Logging.Format = "text"
c.Server.BaseURL = fmt.Sprintf("http://%s:%d", c.Server.Host, c.Server.Port)
}
c.Auth.Realm = getEnv("NIDUS_AUTH_REALM", "DAV Server")
c.Storage.DataDir = getEnv("NIDUS_DATA_DIR", "./data")
c.Logging.Level = getEnv("NIDUS_LOG_LEVEL", "info")
c.Logging.Format = getEnv("NIDUS_LOG_FORMAT", "text")
}
func (c *Config) validate() error {
if c.TLS.Enabled {
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
return fmt.Errorf("tls.cert_file and tls.key_file are required when tls is enabled")
func getEnv(key string, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if val := os.Getenv(key); val != "" {
if intVal, err := strconv.Atoi(val); err == nil {
return intVal
}
}
return nil
return defaultValue
}
-2
View File
@@ -102,8 +102,6 @@ func (d *DB) DisplayName(username string) string {
return u.DisplayName
}
// DeleteUser removes username along with all of its calendars, address
// books, and sharing grants (calendars/addressbooks cascade via foreign
// key; shares are cleaned up explicitly since they reference usernames as
+1 -1
View File
@@ -16,7 +16,7 @@ import (
ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/icalfix"
)
// DefaultTTL is how long a fetched calendar is cached before being
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Migrate restructures the data directory from the old format to the new unified format.
// It is idempotent and can be run multiple times safely.
//
// Old structure:
//
// data/files/<username>/
// data/<username>/cal-<name>/
// data/<username>/card-<name>/
//
// New structure:
//
// data/<username>/files/
// data/<username>/calendars/<name>/
// data/<username>/addressbooks/<name>/
func (s *Store) Migrate() error {
users, err := s.listUserDirectories()
if err != nil {
return fmt.Errorf("listing user directories: %w", err)
}
for _, user := range users {
if err := s.migrateUser(user); err != nil {
return fmt.Errorf("migrating user %q: %w", user, err)
}
}
return nil
}
// listUserDirectories returns all user directories in the data directory.
// It looks for directories that are NOT the old "files" directory,
// and also checks inside the old "files" directory for users who need migrating.
func (s *Store) listUserDirectories() ([]string, error) {
entries, err := os.ReadDir(s.rootDir)
if err != nil {
return nil, err
}
usersMap := make(map[string]bool)
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
// Skip the old files directory (will handle users inside it separately)
if name == "files" {
continue
}
usersMap[name] = true
}
// Also check the old files directory for users
filesDir := filepath.Join(s.rootDir, "files")
if filesEntries, err := os.ReadDir(filesDir); err == nil {
for _, e := range filesEntries {
if e.IsDir() {
usersMap[e.Name()] = true
}
}
}
var users []string
for user := range usersMap {
users = append(users, user)
}
return users, nil
}
// migrateUser migrates a single user's data from old to new structure.
func (s *Store) migrateUser(user string) error {
userDir := filepath.Join(s.rootDir, user)
// Create user directory if it doesn't exist (needed for WebDAV migration)
if err := os.MkdirAll(userDir, 0o755); err != nil {
return fmt.Errorf("creating user directory %q: %w", userDir, err)
}
// Migrate WebDAV files: files/<username> -> <username>/files
if err := s.migrateWebDAV(user); err != nil {
return err
}
// Migrate CalDAV calendars: cal-<name> -> calendars/<name>
if err := s.migrateCalendars(user, userDir); err != nil {
return err
}
// Migrate CardDAV address books: card-<name> -> addressbooks/<name>
if err := s.migrateAddressBooks(user, userDir); err != nil {
return err
}
return nil
}
// migrateWebDAV moves the old files/<username> directory to <username>/files.
func (s *Store) migrateWebDAV(user string) error {
oldPath := filepath.Join(s.rootDir, "files", user)
newPath := filepath.Join(s.rootDir, user, "files")
// Check if old directory exists and new doesn't
if _, err := os.Stat(oldPath); os.IsNotExist(err) {
return nil
}
if _, err := os.Stat(newPath); err == nil {
return nil // Already migrated
}
// Create parent directory if needed
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
return fmt.Errorf("creating directory %q: %w", filepath.Dir(newPath), err)
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving %q to %q: %w", oldPath, newPath, err)
}
return nil
}
// migrateCalendars moves old cal-<name> directories to calendars/<name>.
func (s *Store) migrateCalendars(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var calendars []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "cal-") {
calendars = append(calendars, e.Name())
}
}
if len(calendars) == 0 {
return nil
}
calendarsDir := filepath.Join(userDir, "calendars")
if err := os.MkdirAll(calendarsDir, 0o755); err != nil {
return fmt.Errorf("creating calendars directory %q: %w", calendarsDir, err)
}
for _, calName := range calendars {
oldPath := filepath.Join(userDir, calName)
newPath := filepath.Join(calendarsDir, strings.TrimPrefix(calName, "cal-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving calendar %q: %w", calName, err)
}
}
return nil
}
// migrateAddressBooks moves old card-<name> directories to addressbooks/<name>.
func (s *Store) migrateAddressBooks(user string, userDir string) error {
entries, err := os.ReadDir(userDir)
if err != nil {
return fmt.Errorf("reading user directory %q: %w", userDir, err)
}
var addressBooks []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "card-") {
addressBooks = append(addressBooks, e.Name())
}
}
if len(addressBooks) == 0 {
return nil
}
addressBooksDir := filepath.Join(userDir, "addressbooks")
if err := os.MkdirAll(addressBooksDir, 0o755); err != nil {
return fmt.Errorf("creating addressbooks directory %q: %w", addressBooksDir, err)
}
for _, bookName := range addressBooks {
oldPath := filepath.Join(userDir, bookName)
newPath := filepath.Join(addressBooksDir, strings.TrimPrefix(bookName, "card-"))
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("moving address book %q: %w", bookName, err)
}
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestMigrate(t *testing.T) {
tmpDir := t.TempDir()
// Create old structure
// WebDAV: files/<username>/
if err := os.MkdirAll(filepath.Join(tmpDir, "files", "alice"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "files", "alice", "test.txt"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
// CalDAV: <username>/cal-<name>/
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "cal-work"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "cal-work", "evt.ics"), []byte("calendar"), 0o644); err != nil {
t.Fatal(err)
}
// CardDAV: <username>/card-<name>/
if err := os.MkdirAll(filepath.Join(tmpDir, "bob", "card-contacts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "bob", "card-contacts", "vcard.vcf"), []byte("card"), 0o644); err != nil {
t.Fatal(err)
}
// Create store and migrate
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
// Verify WebDAV: <username>/files/
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
t.Errorf("alice files not migrated: %v", err)
}
// Verify CalDAV: <username>/calendars/<name>
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "calendars", "work", "evt.ics")); err != nil {
t.Errorf("bob calendars not migrated: %v", err)
}
// Verify CardDAV: <username>/addressbooks/<name>
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "addressbooks", "contacts", "vcard.vcf")); err != nil {
t.Errorf("bob addressbooks not migrated: %v", err)
}
// Verify old structure is gone
if _, err := os.Stat(filepath.Join(tmpDir, "files", "alice")); err == nil {
t.Error("old files directory not removed")
}
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "cal-work")); err == nil {
t.Error("old cal- directory not removed")
}
if _, err := os.Stat(filepath.Join(tmpDir, "bob", "card-contacts")); err == nil {
t.Error("old card- directory not removed")
}
}
func TestMigrateIdempotent(t *testing.T) {
tmpDir := t.TempDir()
// Create new structure
if err := os.MkdirAll(filepath.Join(tmpDir, "alice", "files"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "alice", "files", "test.txt"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
// Run migration twice
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
if err := st.Migrate(); err != nil {
t.Fatal(err)
}
// Verify data still there
if _, err := os.Stat(filepath.Join(tmpDir, "alice", "files", "test.txt")); err != nil {
t.Errorf("data not preserved: %v", err)
}
}
func TestMigrateMissingDirectories(t *testing.T) {
tmpDir := t.TempDir()
st, err := NewStore(tmpDir)
if err != nil {
t.Fatal(err)
}
// Should not error on empty directory
if err := st.Migrate(); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
+22 -3
View File
@@ -48,9 +48,26 @@ func (s *Store) lockFor(user string) *sync.RWMutex {
return l
}
// collectionPath returns the filesystem path for a collection.
// collectionPath returns the filesystem path for a collection in the new unified format.
// Calendar collections: <username>/calendars/<name>
// Address book collections: <username>/addressbooks/<name>
// WebDAV collections: <username>/files (all files in one directory)
func (s *Store) collectionPath(user, collection string) string {
return filepath.Join(s.rootDir, sanitize(user), sanitize(collection))
userDir := filepath.Join(s.rootDir, sanitize(user))
if strings.HasPrefix(collection, "cal-") {
return filepath.Join(userDir, "calendars", strings.TrimPrefix(collection, "cal-"))
}
if strings.HasPrefix(collection, "card-") {
return filepath.Join(userDir, "addressbooks", strings.TrimPrefix(collection, "card-"))
}
if collection == "files" {
return filepath.Join(userDir, "files")
}
return filepath.Join(userDir, sanitize(collection))
}
// objectPath returns the filesystem path for an object within a collection.
@@ -68,6 +85,7 @@ func (s *Store) EnsureCollection(user, collection string) error {
}
// ListCollections returns all collection names for a user.
// Returns both old-style (cal-*, card-*) and new-style (calendars/*, addressbooks/*) collections.
func (s *Store) ListCollections(user string) ([]string, error) {
l := s.lockFor(user)
l.RLock()
@@ -85,7 +103,8 @@ func (s *Store) ListCollections(user string) ([]string, error) {
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
name := e.Name()
names = append(names, name)
}
}
return names, nil
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync"
"testing"
"github.com/yourusername/caldav-server/internal/store"
"git.arnef.de/arnef/nidus/internal/store"
)
func TestStoreRoundTrip(t *testing.T) {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http"
"strings"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/web/templates"
)
// handleAccount serves GET /account: the current user's own profile and
+4 -5
View File
@@ -15,11 +15,11 @@ import (
"strings"
"time"
"git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/birthdays"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/web/templates"
)
// eventIDRe validates an event's object ID as it appears in a URL path
@@ -527,7 +527,6 @@ func mondayOf(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
}
// eventDayRange returns the inclusive [start, end] calendar-day span an
// event occupies, in loc, for placing it on the month grid.
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
+2 -2
View File
@@ -15,9 +15,9 @@ import (
"strconv"
"strings"
"git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates"
vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/store"
"github.com/yourusername/caldav-server/internal/web/templates"
)
// contactIDRe validates a contact's object ID as it appears in a URL path
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt"
"net/http"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/web/templates"
)
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -12,7 +12,7 @@ import (
"path/filepath"
"strings"
"github.com/yourusername/caldav-server/internal/web/templates"
"git.arnef.de/arnef/nidus/internal/web/templates"
)
// maxUploadMemory bounds how much of a multipart upload is buffered in
@@ -24,7 +24,7 @@ const maxUploadMemory = 32 << 20 // 32 MiB
// (internal/webdav) serves at /files/, so the web UI is just another view
// onto the same files.
func (s *Server) filesRoot(username string) string {
return filepath.Join(s.cfg.Storage.DataDir, "files", username)
return filepath.Join(s.cfg.Storage.DataDir, username, "files")
}
// sanitizeRelPath cleans a slash-separated relative path (as received from
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"net/http"
"strings"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/web/templates"
)
// icsURLValid does a light sanity check on a subscription URL: it must be
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"net/http"
"github.com/yourusername/caldav-server/internal/web/templates"
"git.arnef.de/arnef/nidus/internal/web/templates"
)
func renderLogin(w http.ResponseWriter, errMsg string) {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"regexp"
"strings"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/web/templates"
)
// resourceNameRe restricts calendar/address book names to characters that
+4 -4
View File
@@ -10,10 +10,10 @@ import (
"net/http"
"strings"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icssub"
"github.com/yourusername/caldav-server/internal/store"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store"
)
// Server holds the dependencies needed by the web UI handlers.
+3 -4
View File
@@ -11,9 +11,9 @@ import (
"strings"
"testing"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
)
func newTestServer(t *testing.T) *Server {
@@ -316,4 +316,3 @@ func TestAccountChangePassword(t *testing.T) {
t.Fatal("expected password to have changed")
}
}
+2 -2
View File
@@ -46,7 +46,7 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
Value: token,
Path: "/",
HttpOnly: true,
Secure: s.cfg.TLS.Enabled,
Secure: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour),
})
@@ -58,7 +58,7 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
Value: "",
Path: "/",
HttpOnly: true,
Secure: s.cfg.TLS.Enabled,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"github.com/yourusername/caldav-server/internal/web/templates"
"git.arnef.de/arnef/nidus/internal/web/templates"
)
// handleCalendarShare handles POST (create/update share) and DELETE
+4 -4
View File
@@ -7,14 +7,14 @@ import (
"path/filepath"
"sync"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config"
xwebdav "golang.org/x/net/webdav"
)
// NewHandler returns an http.Handler that provides standard WebDAV file access,
// mounted at the fixed URL /files/ for every user and rooted at
// dataDir/files/<username>/ on disk. The URL is the same for all users —
// dataDir/<username>/files on disk. The URL is the same for all users —
// which user's directory is served is resolved from the Basic Auth identity
// in the request context, not from the URL.
//
@@ -38,7 +38,7 @@ func NewHandler(cfg *config.Config, dataDir string, logger *slog.Logger) http.Ha
h, ok := handlers[p.Username]
if !ok {
username := p.Username
userDir := filepath.Join(dataDir, "files", username)
userDir := filepath.Join(dataDir, username, "files")
if err := os.MkdirAll(userDir, 0o755); err != nil {
mu.Unlock()
logger.Error("creating user WebDAV dir", "user", username, "error", err)
+5 -5
View File
@@ -9,8 +9,8 @@ import (
"strings"
"testing"
"github.com/yourusername/caldav-server/internal/auth"
filewebdav "github.com/yourusername/caldav-server/internal/webdav"
"git.arnef.de/arnef/nidus/internal/auth"
filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
)
func testLogger() *slog.Logger {
@@ -54,10 +54,10 @@ func TestPerUserIsolationAndPrefix(t *testing.T) {
t.Fatalf("expected bob to get 404 for alice's file, got %d", bobRec.Code)
}
// Confirm the file physically landed under dataDir/files/alice/, not
// Confirm the file physically landed under dataDir/alice/files/, not
// nested under an extra files/files/... path.
if _, err := os.Stat(dir + "/files/alice/note.txt"); err != nil {
t.Fatalf("expected file at dataDir/files/alice/note.txt: %v", err)
if _, err := os.Stat(dir + "/alice/files/note.txt"); err != nil {
t.Fatalf("expected file at dataDir/alice/files/note.txt: %v", err)
}
}
+62
View File
@@ -0,0 +1,62 @@
// Command migrate is a tool to restructure the data directory from the
// old format to the new unified format.
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/store"
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
verbose := fs.Bool("verbose", false, "enable verbose output")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1
}
logger := buildLogger(*verbose)
st, err := store.NewStore(cfg.Storage.DataDir)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening store %q: %v\n", cfg.Storage.DataDir, err)
return 1
}
logger.Info("starting migration", "data_dir", cfg.Storage.DataDir)
if err := st.Migrate(); err != nil {
logger.Error("migration failed", "error", err)
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1
}
logger.Info("migration completed successfully")
fmt.Println("Migration completed successfully")
return 0
}
func buildLogger(verbose bool) *slog.Logger {
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
+54 -48
View File
@@ -8,14 +8,13 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
"git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store"
)
func main() {
@@ -23,18 +22,12 @@ func main() {
}
func run(args []string) int {
fs := flag.NewFlagSet("nidusctl", flag.ContinueOnError)
cfgPath := fs.String("config", "config.yaml", "path to configuration file")
if err := fs.Parse(args); err != nil {
return 2
}
rest := fs.Args()
if len(rest) < 1 {
if len(args) < 1 {
usage()
return 2
}
cfg, err := config.Load(*cfgPath)
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1
@@ -54,18 +47,20 @@ func run(args []string) int {
return 1
}
switch rest[0] {
switch args[0] {
case "user":
return runUser(dbase, rest[1:])
return runUser(dbase, args[1:])
case "calendar", "cal":
return runCalendar(dbase, st, rest[1:])
return runCalendar(dbase, st, args[1:])
case "addressbook", "card":
return runAddressBook(dbase, st, rest[1:])
return runAddressBook(dbase, st, args[1:])
case "migrate":
return runMigrate(st, args[1:])
case "help", "-h", "--help":
usage()
return 0
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", rest[0])
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
usage()
return 2
}
@@ -75,25 +70,32 @@ func usage() {
fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants
Usage:
nidusctl [-config config.yaml] user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl [-config config.yaml] user delete <username>
nidusctl [-config config.yaml] user list
nidusctl [-config config.yaml] user passwd <username> [--password PW]
nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl user delete <username>
nidusctl user list
nidusctl user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color>
nidusctl [-config config.yaml] calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write>
nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user>
nidusctl [-config config.yaml] calendar shares <owner> <calendar>
nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl calendar color <owner> <calendar> <hex-color>
nidusctl calendar delete <owner> <calendar>
nidusctl calendar list <owner>
nidusctl calendar share <owner> <calendar> <user> <read|write>
nidusctl calendar unshare <owner> <calendar> <user>
nidusctl calendar shares <owner> <calendar>
nidusctl [-config config.yaml] addressbook create <owner> <book>
nidusctl [-config config.yaml] addressbook delete <owner> <book>
nidusctl [-config config.yaml] addressbook list <owner>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user>
nidusctl [-config config.yaml] addressbook shares <owner> <book>
nidusctl addressbook create <owner> <book>
nidusctl addressbook delete <owner> <book>
nidusctl addressbook list <owner>
nidusctl addressbook share <owner> <book> <user> <read|write>
nidusctl addressbook unshare <owner> <book> <user>
nidusctl addressbook shares <owner> <book>
nidusctl migrate [--verbose]
nidusctl help
Configuration is done via environment variables:
NIDUS_DATA_DIR - data directory (default: ./data)
Examples:
nidusctl user create alice --display-name "Alice Smith" --email alice@example.com
@@ -101,16 +103,10 @@ Examples:
nidusctl calendar share alice work bob write
nidusctl calendar shares alice work
nidusctl calendar unshare alice work bob
nidusctl migrate
`)
}
// newFlagSet creates a flag.FlagSet configured for subcommand parsing
// (flags may appear before or after positional args, since callers parse
// flags first with fs.Parse then read fs.Args() for the rest).
func newFlagSet(name string) *flag.FlagSet {
return flag.NewFlagSet(name, flag.ContinueOnError)
}
func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
if len(args) < 1 {
usage()
@@ -118,17 +114,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
}
switch args[0] {
case "create":
fs := newFlagSet("calendar create")
color := fs.String("color", "", "hex color like #3b82f6 (optional)")
if err := fs.Parse(args[1:]); err != nil {
return 2
color := ""
if len(args) > 2 && args[1] == "--color" {
color = args[2]
args = args[:1]
}
if fs.NArg() != 2 {
if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']")
return 2
}
owner, calName := fs.Arg(0), fs.Arg(1)
if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil {
owner, calName := args[1], args[2]
if err := dbase.CreateCalendarWithColor(owner, calName, color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
@@ -359,6 +355,16 @@ func runAddressBook(dbase *db.DB, st *store.Store, args []string) int {
}
}
func runMigrate(st *store.Store, args []string) int {
// Ignore args for now (could add --verbose flag in future if needed)
if err := st.Migrate(); err != nil {
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1
}
fmt.Println("Migration completed successfully")
return 0
}
// warnIfUnknownUser reports (without failing) if username is not a
// registered user — the share is still recorded, since a user could be
// created afterwards.
+19 -39
View File
@@ -9,28 +9,9 @@ import (
"strings"
"testing"
"github.com/yourusername/caldav-server/internal/db"
"git.arnef.de/arnef/nidus/internal/db"
)
// writeTestConfig creates a minimal config.yaml in dir and returns its path.
func writeTestConfig(t *testing.T, dir string) string {
t.Helper()
cfgPath := filepath.Join(dir, "config.yaml")
dataDir := filepath.Join(dir, "data")
content := "storage:\n data_dir: " + dataDir + "\n" +
"users:\n" +
" alice:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n" +
" bob:\n" +
" password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n"
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
t.Fatalf("writing test config: %v", err)
}
return cfgPath
}
// runCLI runs the CLI's run() function, capturing stdout/stderr, and
// returns (exit code, combined stdout+stderr).
func runCLI(t *testing.T, args ...string) (int, string) {
t.Helper()
@@ -56,9 +37,9 @@ func runCLI(t *testing.T, args ...string) (int, string) {
func TestCalendarShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir()
cfgPath := writeTestConfig(t, dir)
t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "write")
code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "write")
if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out)
}
@@ -66,7 +47,7 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain 'shared'", out)
}
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work")
code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out)
}
@@ -74,12 +55,12 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/write", out)
}
code, out = runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "work", "bob")
code, out = runCLI(t, "calendar", "unshare", "alice", "work", "bob")
if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out)
}
code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work")
code, out = runCLI(t, "calendar", "shares", "alice", "work")
if code != 0 {
t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out)
}
@@ -90,9 +71,9 @@ func TestCalendarShareUnshareLifecycle(t *testing.T) {
func TestCalendarShareInvalidPermission(t *testing.T) {
dir := t.TempDir()
cfgPath := writeTestConfig(t, dir)
t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "admin")
code, out := runCLI(t, "calendar", "share", "alice", "work", "bob", "admin")
if code != 2 {
t.Errorf("exit code = %d, want 2; output: %s", code, out)
}
@@ -103,9 +84,9 @@ func TestCalendarShareInvalidPermission(t *testing.T) {
func TestCalendarUnshareNotFound(t *testing.T) {
dir := t.TempDir()
cfgPath := writeTestConfig(t, dir)
t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "ghost", "bob")
code, out := runCLI(t, "calendar", "unshare", "alice", "ghost", "bob")
if code != 1 {
t.Errorf("exit code = %d, want 1; output: %s", code, out)
}
@@ -116,14 +97,14 @@ func TestCalendarUnshareNotFound(t *testing.T) {
func TestAddressBookShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir()
cfgPath := writeTestConfig(t, dir)
t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "addressbook", "share", "alice", "contacts", "bob", "read")
code, out := runCLI(t, "addressbook", "share", "alice", "contacts", "bob", "read")
if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out)
}
code, out = runCLI(t, "-config", cfgPath, "addressbook", "shares", "alice", "contacts")
code, out = runCLI(t, "addressbook", "shares", "alice", "contacts")
if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out)
}
@@ -131,7 +112,7 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
t.Errorf("output = %q, want to contain bob/read", out)
}
code, out = runCLI(t, "-config", cfgPath, "addressbook", "unshare", "alice", "contacts", "bob")
code, out = runCLI(t, "addressbook", "unshare", "alice", "contacts", "bob")
if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out)
}
@@ -139,9 +120,9 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
dir := t.TempDir()
cfgPath := writeTestConfig(t, dir)
t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "carol", "read")
code, out := runCLI(t, "calendar", "share", "alice", "work", "carol", "read")
if code != 0 {
t.Fatalf("exit code = %d, output: %s", code, out)
}
@@ -154,10 +135,9 @@ func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
}
func TestNoArgsShowsUsage(t *testing.T) {
dir := t.TempDir()
// No config needed since usage() is printed before config.Load for
// missing subcommands.
code, out := runCLI(t, "-config", filepath.Join(dir, "missing.yaml"))
code, out := runCLI(t)
if code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
@@ -168,9 +148,9 @@ func TestNoArgsShowsUsage(t *testing.T) {
func TestUnknownCommand(t *testing.T) {
dir := t.TempDir()
cfgPath := writeTestConfig(t, dir)
t.Setenv("NIDUS_DATA_DIR", filepath.Join(dir, "data"))
code, out := runCLI(t, "-config", cfgPath, "bogus")
code, out := runCLI(t, "bogus")
if code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
+45 -21
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/yourusername/caldav-server/internal/db"
"git.arnef.de/arnef/nidus/internal/db"
"golang.org/x/term"
)
@@ -31,31 +31,47 @@ func runUser(dbase *db.DB, args []string) int {
}
func userCreate(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user create")
displayName := fs.String("display-name", "", "display name shown in DAV clients")
email := fs.String("email", "", "email address")
password := fs.String("password", "", "password (omit to be prompted, recommended)")
if err := fs.Parse(args); err != nil {
return 2
var displayName, email, password string
var rest []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--display-name":
if i+1 < len(args) {
displayName = args[i+1]
i++
}
case "--email":
if i+1 < len(args) {
email = args[i+1]
i++
}
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
}
rest := fs.Args()
if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]")
return 2
}
username := rest[0]
pw := *password
if pw == "" {
if password == "" {
var err error
pw, err = promptPassword(username)
password, err = promptPassword(username)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1
}
}
if err := dbase.CreateUser(username, pw, *displayName, *email); err != nil {
if err := dbase.CreateUser(username, password, displayName, email); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
@@ -98,29 +114,37 @@ func userList(dbase *db.DB, args []string) int {
}
func userPasswd(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user passwd")
password := fs.String("password", "", "new password (omit to be prompted, recommended)")
if err := fs.Parse(args); err != nil {
return 2
var password string
var rest []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
}
rest := fs.Args()
if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]")
return 2
}
username := rest[0]
pw := *password
if pw == "" {
if password == "" {
var err error
pw, err = promptPassword(username)
password, err = promptPassword(username)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1
}
}
if err := dbase.SetPassword(username, pw); err != nil {
if err := dbase.SetPassword(username, password); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}