6 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
19 changed files with 304 additions and 481 deletions
+9 -6
View File
@@ -18,13 +18,16 @@ WORKDIR /app
COPY --from=builder /bin/davserver /usr/local/bin/davserver COPY --from=builder /bin/davserver /usr/local/bin/davserver
COPY --from=builder /bin/nidusctl /usr/local/bin/nidusctl COPY --from=builder /bin/nidusctl /usr/local/bin/nidusctl
# Default data and config locations # Default data location
VOLUME ["/app/data"] 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 # environment variables (override as needed)
# config.yaml over /app/config.yaml (see docker-compose.yaml) to override it. ENV NIDUS_DATA_DIR=/app/data
COPY config.example.yaml /app/config.yaml ENV NIDUS_HOST=0.0.0.0
ENV NIDUS_PORT=8080
ENV NIDUS_LOG_LEVEL=warn
ENV NIDUS_LOG_FORMAT=text
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["davserver", "-config", "/app/config.yaml"] ENTRYPOINT ["davserver"]
-150
View File
@@ -1,150 +0,0 @@
# Data Directory Migration Guide
This migration unifies the user data directory structure from a fragmented layout to a consistent nested format.
## Before (Old Structure)
```
data/
├── files/
│ └── alice/ # WebDAV files
│ └── documents/
│ └── file.txt
├── alice/
│ ├── cal-personal/ # CalDAV calendar
│ │ └── event1.ics
│ └── card-contacts/ # CardDAV address book
│ └── contact1.vcf
└── bob/
├── cal-work/
│ └── meeting.ics
└── card-addressbook/
└── address.vcf
```
## After (New Unified Structure)
```
data/
├── alice/
│ ├── files/ # WebDAV files
│ │ └── documents/
│ │ └── file.txt
│ ├── calendars/ # CalDAV calendars
│ │ └── personal/
│ │ └── event1.ics
│ └── addressbooks/ # CardDAV address books
│ └── contacts/
│ └── contact1.vcf
└── bob/
├── files/
├── calendars/
│ └── work/
└── addressbooks/
└── addressbook/
```
## Migration Details
### What Changed
1. **WebDAV files**: `data/files/<username>/``data/<username>/files/`
2. **CalDAV calendars**: `data/<username>/cal-<name>/``data/<username>/calendars/<name>/`
3. **CardDAV address books**: `data/<username>/card-<name>/``data/<username>/addressbooks/<name>/`
### Migration Tool
A migration tool is provided that automatically restructures the data directory. It is **idempotent** and can be run multiple times safely.
#### Using nidusctl (Recommended)
```bash
go run ./tools/nidusctl -config config.yaml migrate
```
#### Using standalone migrate tool
```bash
go run ./tools/migrate -config config.yaml
```
#### Verbose output
```bash
go run ./tools/nidusctl -config config.yaml migrate --verbose
```
### What the Migration Does
1. For each user directory in the data directory:
- Creates `<username>/` if it doesn't exist
- Moves `files/<username>/``<username>/files/` (if exists)
- Moves `cal-<name>/``<username>/calendars/<name>/`
- Moves `card-<name>/``<username>/addressbooks/<name>/`
2. The old `files/` directory is left in place (can be manually removed after verification)
### Backward Compatibility
The migration is **fully backward compatible**:
- The migration tool handles both old and new structures
- If files are already in the new location, they are not moved
- Running migration multiple times is safe (idempotent)
### Server Integration
The server automatically runs migration on startup (if enabled in config). No manual migration is required for new installations.
### Testing
After migration, verify:
```bash
# Check data structure
ls -la data/
# Run tests
go test ./...
# Start server to verify WebDAV, CalDAV, CardDAV work correctly
go run ./cmd/server -config config.yaml
```
### Manual Verification
After migration, you should see:
```bash
$ ls -la data/alice/
calendars/
addressbooks/
files/
```
Each calendar should be in `data/<user>/calendars/<name>/` format, not `cal-<name>/`.
### Rollback (if needed)
If you need to rollback:
1. Stop the server
2. Restore the data directory from backup
3. Re-run the migration after fixing any issues
### Common Issues
**Q: Migration reports "directory already exists" warnings**
A: These are normal if files were already migrated. The migration is idempotent.
**Q: Old `files/` directory still exists**
A: This is expected. You can manually remove it after verifying migration success.
**Q: Some calendars/address books not visible after migration**
A: Check the migration logs and verify directory structure. Run `find data/ -type d -name "cal-*"` to find unmigrated calendars.
### Support
If you encounter issues:
1. Check logs for detailed error messages
2. Run migration with `--verbose` flag
3. Ensure no server processes are running during migration
4. Make backup before migrating in production
+2 -2
View File
@@ -7,7 +7,7 @@ build:
## run: run the server locally ## run: run the server locally
run: build run: build
./bin/davserver -config config.yaml ./bin/davserver
## test: run all tests ## test: run all tests
test: test:
@@ -37,7 +37,7 @@ hash-password:
## nidusctl: build and run the sharing-grant admin CLI ## nidusctl: build and run the sharing-grant admin CLI
## Usage: make nidusctl ARGS="calendar share alice work bob write" ## Usage: make nidusctl ARGS="calendar share alice work bob write"
nidusctl: build nidusctl: build
./bin/nidusctl -config config.yaml $(ARGS) ./bin/nidusctl $(ARGS)
## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ ## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ
templ-generate: templ-generate:
+94 -84
View File
@@ -1,4 +1,4 @@
# DAV Server # nidus
A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go. 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, - **Web UI** — a mobile-friendly app at `/web/` for managing calendars,
contacts, files, and account settings (see [Web UI](#web-ui) below), contacts, files, and account settings (see [Web UI](#web-ui) below),
built with templ + Tailwind + htmx 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` - Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav`
- Optional **TLS** (or use a reverse proxy)
- Structured logging (text or JSON) - Structured logging (text or JSON)
- Graceful shutdown - Graceful shutdown
- Docker & Docker Compose support - Docker & Docker Compose support
## Quick start ## Quick start
### 1. Install dependencies ### 1. Run the server
```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
```bash ```bash
make run make run
# or # or
go run ./cmd/server -config config.yaml go run ./cmd/server
``` ```
The server starts at **http://localhost:8080**. 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 ```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 --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, e.g. in scripts)
go run ./tools/nidusctl -config config.yaml calendar create alice personal go run ./tools/nidusctl calendar create alice personal
go run ./tools/nidusctl -config config.yaml addressbook create alice contacts go run ./tools/nidusctl addressbook create alice contacts
``` ```
Users can also be created/removed via the web UI (`/web/`) once logged in Or use the web UI (`/web/`) once logged in — see **Web UI** below.
as an existing user — see **Web UI** below.
--- ---
@@ -83,27 +88,27 @@ as an existing user — see **Web UI** below.
docker compose up --build docker compose up --build
# Or build manually # Or build manually
docker build -t davserver . docker build -t nidus .
docker run -p 8080:8080 \ docker run -p 8080:8080 \
-v ./config.yaml:/app/config.yaml:ro \ -v nidus-data:/app/data \
-v dav-data:/app/data \ -e NIDUS_DATA_DIR=/app/data \
davserver nidus
``` ```
The image also ships `nidusctl`, so once the container is running you can The image also ships `nidusctl`, so once the container is running you can
create your first user (and their calendars/address books) with create your first user (and their calendars/address books) with
`docker compose exec` — no need to install Go locally: `docker compose exec`:
```bash ```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 --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 nidus nidusctl calendar create alice personal
docker compose exec davserver nidusctl -config /app/config.yaml addressbook create alice contacts 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 Pushing a version tag (e.g. `v1.2.3`) or publishing a release triggers
[`.github/workflows/docker-release.yml`](.github/workflows/docker-release.yml), [`.github/workflows/docker-release.yml`](.github/workflows/docker-release.yml),
@@ -118,25 +123,29 @@ building from a checkout — just fetch `config.example.yaml`, copy it to
```yaml ```yaml
services: services:
davserver: nidus:
image: git.arnef.de/arnef/nidus:latest image: git.arnef.de/arnef/nidus:latest
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/app/config.yaml:ro - nidus-data:/app/data
- dav-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 restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
volumes: volumes:
dav-data: 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 ## API endpoints
@@ -208,10 +217,10 @@ mobile browsers:
`nidus.db` (`web_sessions` table), independent of DAV Basic Auth. Includes `nidus.db` (`web_sessions` table), independent of DAV Basic Auth. Includes
a "Show/Hide" password toggle to rule out typos before submitting. a "Show/Hide" password toggle to rule out typos before submitting.
- **Dashboard** (`/web/`) — create/delete your own calendars, address - **Dashboard** (`/web/`) — create/delete your own calendars, address
books, and ICS/webcal subscriptions; see who your resources are shared books, ICS/webcal subscriptions, and the Birthdays calendar; see who your
with and what others have shared with you; manage sharing grants resources are shared with and what others have shared with you; manage
directly (same effect as `nidusctl`) — updates happen in place via sharing grants directly (same effect as `nidusctl`) — updates happen in
[htmx](https://htmx.org/) without a full page reload. place via [htmx](https://htmx.org/) without a full page reload.
- **Files** (`/web/files/`) — a browser for the same storage the WebDAV - **Files** (`/web/files/`) — a browser for the same storage the WebDAV
endpoint (`/files/`) serves: navigate folders, create new folders, endpoint (`/files/`) serves: navigate folders, create new folders,
upload files/folders (including via drag & drop), download, and delete upload files/folders (including via drag & drop), download, and delete
@@ -223,8 +232,9 @@ mobile browsers:
delete contacts (name, organization, birthday, phone numbers, emails, delete contacts (name, organization, birthday, phone numbers, emails,
addresses, photo), and import/export vCards (`.vcf`). addresses, photo), and import/export vCards (`.vcf`).
- **Calendar** (`/web/calendar`) — month and week views across all your - **Calendar** (`/web/calendar`) — month and week views across all your
own and shared calendars, create/edit/delete events, per-calendar own and shared calendars (including ICS/webcal subscriptions and the
colors, and import/export `.ics` files. Birthdays calendar), create/edit/delete events, per-calendar colors,
and import/export `.ics` files.
- **Account** (`/web/account`) — update your display name/email and - **Account** (`/web/account`) — update your display name/email and
change your password. change your password.
- **Logout** (`/web/logout`). - **Logout** (`/web/logout`).
@@ -248,35 +258,34 @@ make web-assets # regenerate templ code + rebuild web/static/app.css and web/st
## Configuration reference ## Configuration reference
```yaml Configuration is done via environment variables:
server:
host: "0.0.0.0"
port: 8080
base_url: "https://dav.example.com" # used in DAV responses
auth: | Variable | Default | Description |
realm: "My DAV Server" |----------|---------|-------------|
| `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: Example:
data_dir: "./data" # all user data lives here
logging: ```bash
level: "info" # debug | info | warn | error export NIDUS_DATA_DIR="./data"
format: "text" # text | json export NIDUS_PORT="8080"
export NIDUS_BASE_URL="https://dav.example.com"
tls: export NIDUS_LOG_LEVEL="info"
enabled: false
cert_file: ""
key_file: ""
``` ```
Users, calendars, and address books are managed via `nidusctl`, not This server does **not** handle TLS — use a reverse proxy (e.g. Nginx, Caddy,
`config.yaml` — see **Managing users** below. Traefik) to terminate TLS and forward requests to the server.
## Managing users ## Managing users
All user/calendar/address-book management is done with `nidusctl` (or the All user/calendar/address-book management is done with `nidusctl` or the
web UI). Nothing is stored in `config.yaml` anymore. web UI (`/web/`). Nothing is stored in `config.yaml` anymore.
```bash ```bash
# Users # Users
@@ -303,9 +312,10 @@ nidusctl addressbook unshare <owner> <book> <user>
nidusctl addressbook shares <owner> <book> 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 `--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 > **Upgrading from an older version?** The `users:` section in
> `config.yaml` is no longer read. Recreate your users with > `config.yaml` is no longer read. Recreate your users with
@@ -317,22 +327,22 @@ create/delete their own calendars and address books from the dashboard.
## Project layout ## Project layout
``` ```
caldav-server/ nidus/
├── cmd/server/ # main entrypoint ├── cmd/server/ # main entrypoint
├── internal/ ├── internal/
│ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints) │ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints)
│ ├── caldav/ # CalDAV backend │ ├── caldav/ # CalDAV backend
│ ├── carddav/ # CardDAV backend │ ├── carddav/ # CardDAV backend
│ ├── config/ # YAML config loader │ ├── config/ # YAML config loader
│ ├── db/ # SQLite store (shares, web UI sessions) │ ├── db/ # SQLite store (users, calendars, shares, sessions)
│ ├── store/ # filesystem storage layer │ ├── store/ # filesystem storage layer
│ ├── web/ # web UI (cookie sessions, dashboard, share mgmt)
│ │ └── templates/ # templ templates (+ generated *_templ.go)
│ └── webdav/ # WebDAV file handler │ └── webdav/ # WebDAV file handler
├── tools/hashpwd/ # bcrypt password hasher CLI ├── internal/web/ # web UI (templ, dashboard, share mgmt, sessions)
├── tools/nidusctl/ # sharing-grant admin CLI │ └── templates/ # templ templates (+ generated *_templ.go)
├── cmd/nidusctl/ # admin CLI (users, calendars, address books, sharing)
├── web/ # front-end assets: Tailwind input/config, static/ ├── web/ # front-end assets: Tailwind input/config, static/
│ └── static/ # compiled app.css + htmx.min.js (embedded into the binary) │ └── 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) ├── config.example.yaml # sample configuration (copy to config.yaml)
├── Dockerfile ├── Dockerfile
├── docker-compose.yaml ├── docker-compose.yaml
+2 -12
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -25,12 +24,8 @@ import (
) )
func main() { func main() {
var cfgPath string
flag.StringVar(&cfgPath, "config", "config.yaml", "path to configuration file")
flag.Parse()
// ---- Configuration ---- // ---- Configuration ----
cfg, err := config.Load(cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -41,8 +36,7 @@ func main() {
logger.Info("starting DAV server", logger.Info("starting DAV server",
"host", cfg.Server.Host, "host", cfg.Server.Host,
"port", cfg.Server.Port, "port", cfg.Server.Port,
"base_url", cfg.Server.BaseURL, "base_url", cfg.Server.BaseURL)
"tls", cfg.TLS.Enabled)
// ---- Storage ---- // ---- Storage ----
st, err := store.NewStore(cfg.Storage.DataDir) st, err := store.NewStore(cfg.Storage.DataDir)
@@ -143,11 +137,7 @@ func main() {
}() }()
logger.Info("server ready", "addr", addr) 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 { if err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err) logger.Error("server error", "error", err)
os.Exit(1) os.Exit(1)
+28 -29
View File
@@ -1,31 +1,30 @@
server: # nidus configuration via environment variables
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/):
# #
# nidusctl user create alice --password mysecretpassword --display-name "Alice Smith" --email alice@example.com # Environment variables override any defaults shown below.
# nidusctl calendar create alice personal # Set them before starting the server.
# nidusctl calendar create alice work
# nidusctl addressbook create alice contacts
# #
# 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: services:
davserver: nidus:
build: . build: .
ports: ports:
- "8080:8080" - "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: volumes:
- ./config.yaml:/app/config.yaml:ro - nidus-data:/app/data
- dav-data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
@@ -14,4 +22,4 @@ services:
retries: 3 retries: 3
volumes: volumes:
dav-data: nidus-data:
+3 -3
View File
@@ -14,15 +14,15 @@ import (
"strings" "strings"
"time" "time"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix" "git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/icssub" "git.arnef.de/arnef/nidus/internal/icssub"
"git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
) )
// sharedNameSep separates the owner from the calendar name in the // sharedNameSep separates the owner from the calendar name in the
+1 -1
View File
@@ -11,11 +11,11 @@ import (
"strings" "strings"
"testing" "testing"
ical "github.com/emersion/go-ical"
"git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/store"
ical "github.com/emersion/go-ical"
) )
func newTestBackend(t *testing.T) (*Backend, *db.DB) { func newTestBackend(t *testing.T) (*Backend, *db.DB) {
+3 -3
View File
@@ -9,13 +9,13 @@ import (
"strings" "strings"
"time" "time"
vcard "github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/carddav"
"git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/carddav"
) )
// sharedNameSep separates the owner from the address book name in the // sharedNameSep separates the owner from the address book name in the
+1 -1
View File
@@ -8,11 +8,11 @@ import (
"strings" "strings"
"testing" "testing"
vcard "github.com/emersion/go-vcard"
"git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/auth"
"git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/config"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/store"
vcard "github.com/emersion/go-vcard"
) )
func newTestBackend(t *testing.T) (*Backend, *db.DB) { func newTestBackend(t *testing.T) (*Backend, *db.DB) {
+37 -64
View File
@@ -3,97 +3,70 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"gopkg.in/yaml.v3"
) )
// Config is the top-level server configuration. // Config is the top-level server configuration.
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig
Auth AuthConfig `yaml:"auth"` Auth AuthConfig
Storage StorageConfig `yaml:"storage"` Storage StorageConfig
TLS TLSConfig `yaml:"tls"` Logging LoggingConfig
Logging LoggingConfig `yaml:"logging"`
} }
type ServerConfig struct { type ServerConfig struct {
Host string `yaml:"host"` Host string
Port int `yaml:"port"` Port int
// Base URL used in DAV responses (e.g. https://dav.example.com) BaseURL string
BaseURL string `yaml:"base_url"`
} }
type AuthConfig struct { type AuthConfig struct {
// Realm shown in WWW-Authenticate header Realm string
Realm string `yaml:"realm"`
} }
type StorageConfig struct { type StorageConfig struct {
// Root directory for all data DataDir string
DataDir string `yaml:"data_dir"`
}
type TLSConfig struct {
Enabled bool `yaml:"enabled"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
} }
type LoggingConfig struct { type LoggingConfig struct {
Level string `yaml:"level"` // debug | info | warn | error Level string
Format string `yaml:"format"` // text | json Format string
} }
// Load reads and parses a YAML config file. // Load reads and parses environment variables to create the configuration.
func Load(path string) (*Config, error) { func Load() (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %q: %w", path, err)
}
cfg := &Config{} cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %q: %w", path, err)
}
cfg.applyDefaults() cfg.applyDefaults()
return cfg, nil
return cfg, cfg.validate()
} }
func (c *Config) applyDefaults() { func (c *Config) applyDefaults() {
if c.Server.Host == "" { c.Server.Host = getEnv("NIDUS_HOST", "0.0.0.0")
c.Server.Host = "0.0.0.0" c.Server.Port = getEnvInt("NIDUS_PORT", 8080)
} c.Server.BaseURL = getEnv("NIDUS_BASE_URL", "")
if c.Server.Port == 0 {
c.Server.Port = 8080
}
if c.Server.BaseURL == "" { if c.Server.BaseURL == "" {
scheme := "http" c.Server.BaseURL = fmt.Sprintf("http://%s:%d", c.Server.Host, c.Server.Port)
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.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 { func getEnv(key string, defaultValue string) string {
if c.TLS.Enabled { if val := os.Getenv(key); val != "" {
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" { return val
return fmt.Errorf("tls.cert_file and tls.key_file are required when tls is enabled")
} }
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
} }
+1 -1
View File
@@ -15,11 +15,11 @@ import (
"strings" "strings"
"time" "time"
ical "github.com/emersion/go-ical"
"git.arnef.de/arnef/nidus/internal/birthdays" "git.arnef.de/arnef/nidus/internal/birthdays"
"git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/db"
"git.arnef.de/arnef/nidus/internal/icalfix" "git.arnef.de/arnef/nidus/internal/icalfix"
"git.arnef.de/arnef/nidus/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
ical "github.com/emersion/go-ical"
) )
// eventIDRe validates an event's object ID as it appears in a URL path // eventIDRe validates an event's object ID as it appears in a URL path
+1 -1
View File
@@ -15,9 +15,9 @@ import (
"strconv" "strconv"
"strings" "strings"
vcard "github.com/emersion/go-vcard"
"git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/store"
"git.arnef.de/arnef/nidus/internal/web/templates" "git.arnef.de/arnef/nidus/internal/web/templates"
vcard "github.com/emersion/go-vcard"
) )
// contactIDRe validates a contact's object ID as it appears in a URL path // contactIDRe validates a contact's object ID as it appears in a URL path
+2 -2
View File
@@ -46,7 +46,7 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
Value: token, Value: token,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: s.cfg.TLS.Enabled, Secure: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour), Expires: time.Now().Add(7 * 24 * time.Hour),
}) })
@@ -58,7 +58,7 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: s.cfg.TLS.Enabled, Secure: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: -1, MaxAge: -1,
}) })
+1 -2
View File
@@ -18,13 +18,12 @@ func main() {
func run(args []string) int { func run(args []string) int {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError) fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
cfgPath := fs.String("config", "config.yaml", "path to configuration file")
verbose := fs.Bool("verbose", false, "enable verbose output") verbose := fs.Bool("verbose", false, "enable verbose output")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
cfg, err := config.Load(*cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1 return 1
+40 -53
View File
@@ -8,7 +8,6 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -23,18 +22,12 @@ func main() {
} }
func run(args []string) int { func run(args []string) int {
fs := flag.NewFlagSet("nidusctl", flag.ContinueOnError) if len(args) < 1 {
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 {
usage() usage()
return 2 return 2
} }
cfg, err := config.Load(*cfgPath) cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
return 1 return 1
@@ -54,20 +47,20 @@ func run(args []string) int {
return 1 return 1
} }
switch rest[0] { switch args[0] {
case "user": case "user":
return runUser(dbase, rest[1:]) return runUser(dbase, args[1:])
case "calendar", "cal": case "calendar", "cal":
return runCalendar(dbase, st, rest[1:]) return runCalendar(dbase, st, args[1:])
case "addressbook", "card": case "addressbook", "card":
return runAddressBook(dbase, st, rest[1:]) return runAddressBook(dbase, st, args[1:])
case "migrate": case "migrate":
return runMigrate(st, rest[1:]) return runMigrate(st, args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
usage() usage()
return 0 return 0
default: default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", rest[0]) fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
usage() usage()
return 2 return 2
} }
@@ -77,27 +70,32 @@ func usage() {
fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants
Usage: Usage:
nidusctl [-config config.yaml] user create <username> [--display-name NAME] [--email EMAIL] [--password PW] nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl [-config config.yaml] user delete <username> nidusctl user delete <username>
nidusctl [-config config.yaml] user list nidusctl user list
nidusctl [-config config.yaml] user passwd <username> [--password PW] nidusctl user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB'] nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color> nidusctl calendar color <owner> <calendar> <hex-color>
nidusctl [-config config.yaml] calendar delete <owner> <calendar> nidusctl calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner> nidusctl calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write> nidusctl calendar share <owner> <calendar> <user> <read|write>
nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user> nidusctl calendar unshare <owner> <calendar> <user>
nidusctl [-config config.yaml] calendar shares <owner> <calendar> nidusctl calendar shares <owner> <calendar>
nidusctl [-config config.yaml] addressbook create <owner> <book> nidusctl addressbook create <owner> <book>
nidusctl [-config config.yaml] addressbook delete <owner> <book> nidusctl addressbook delete <owner> <book>
nidusctl [-config config.yaml] addressbook list <owner> nidusctl addressbook list <owner>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write> nidusctl addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user> nidusctl addressbook unshare <owner> <book> <user>
nidusctl [-config config.yaml] addressbook shares <owner> <book> nidusctl addressbook shares <owner> <book>
nidusctl [-config config.yaml] migrate [--verbose] nidusctl migrate [--verbose]
nidusctl help
Configuration is done via environment variables:
NIDUS_DATA_DIR - data directory (default: ./data)
Examples: Examples:
nidusctl user create alice --display-name "Alice Smith" --email alice@example.com nidusctl user create alice --display-name "Alice Smith" --email alice@example.com
@@ -106,14 +104,7 @@ Examples:
nidusctl calendar shares alice work nidusctl calendar shares alice work
nidusctl calendar unshare alice work bob nidusctl calendar unshare alice work bob
nidusctl migrate 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 { func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
@@ -123,17 +114,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
} }
switch args[0] { switch args[0] {
case "create": case "create":
fs := newFlagSet("calendar create") color := ""
color := fs.String("color", "", "hex color like #3b82f6 (optional)") if len(args) > 2 && args[1] == "--color" {
if err := fs.Parse(args[1:]); err != nil { color = args[2]
return 2 args = args[:1]
} }
if fs.NArg() != 2 { if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']") fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']")
return 2 return 2
} }
owner, calName := fs.Arg(0), fs.Arg(1) owner, calName := args[1], args[2]
if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil { if err := dbase.CreateCalendarWithColor(owner, calName, color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -365,11 +356,7 @@ func runAddressBook(dbase *db.DB, st *store.Store, args []string) int {
} }
func runMigrate(st *store.Store, args []string) int { func runMigrate(st *store.Store, args []string) int {
fs := newFlagSet("migrate") // Ignore args for now (could add --verbose flag in future if needed)
if err := fs.Parse(args); err != nil {
return 2
}
if err := st.Migrate(); err != nil { if err := st.Migrate(); err != nil {
fmt.Fprintf(os.Stderr, "migration failed: %v\n", err) fmt.Fprintf(os.Stderr, "migration failed: %v\n", err)
return 1 return 1
+18 -38
View File
@@ -12,25 +12,6 @@ import (
"git.arnef.de/arnef/nidus/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) { func runCLI(t *testing.T, args ...string) (int, string) {
t.Helper() t.Helper()
@@ -56,9 +37,9 @@ func runCLI(t *testing.T, args ...string) (int, string) {
func TestCalendarShareUnshareLifecycle(t *testing.T) { func TestCalendarShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() 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 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) 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) 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 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) 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) 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 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) 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 { if code != 0 {
t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out) 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) { func TestCalendarShareInvalidPermission(t *testing.T) {
dir := t.TempDir() 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 { if code != 2 {
t.Errorf("exit code = %d, want 2; output: %s", code, out) 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) { func TestCalendarUnshareNotFound(t *testing.T) {
dir := t.TempDir() 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 { if code != 1 {
t.Errorf("exit code = %d, want 1; output: %s", code, out) 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) { func TestAddressBookShareUnshareLifecycle(t *testing.T) {
dir := t.TempDir() 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 { if code != 0 {
t.Fatalf("share exit code = %d, output: %s", code, out) 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 { if code != 0 {
t.Fatalf("shares exit code = %d, output: %s", code, out) 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) 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 { if code != 0 {
t.Fatalf("unshare exit code = %d, output: %s", code, out) t.Fatalf("unshare exit code = %d, output: %s", code, out)
} }
@@ -139,9 +120,9 @@ func TestAddressBookShareUnshareLifecycle(t *testing.T) {
func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) { func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
dir := t.TempDir() 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 { if code != 0 {
t.Fatalf("exit code = %d, output: %s", code, out) t.Fatalf("exit code = %d, output: %s", code, out)
} }
@@ -154,10 +135,9 @@ func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) {
} }
func TestNoArgsShowsUsage(t *testing.T) { func TestNoArgsShowsUsage(t *testing.T) {
dir := t.TempDir()
// No config needed since usage() is printed before config.Load for // No config needed since usage() is printed before config.Load for
// missing subcommands. // missing subcommands.
code, out := runCLI(t, "-config", filepath.Join(dir, "missing.yaml")) code, out := runCLI(t)
if code != 2 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
@@ -168,9 +148,9 @@ func TestNoArgsShowsUsage(t *testing.T) {
func TestUnknownCommand(t *testing.T) { func TestUnknownCommand(t *testing.T) {
dir := t.TempDir() 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 { if code != 2 {
t.Errorf("exit code = %d, want 2", code) t.Errorf("exit code = %d, want 2", code)
} }
+44 -20
View File
@@ -31,31 +31,47 @@ func runUser(dbase *db.DB, args []string) int {
} }
func userCreate(dbase *db.DB, args []string) int { func userCreate(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user create") var displayName, email, password string
displayName := fs.String("display-name", "", "display name shown in DAV clients") var rest []string
email := fs.String("email", "", "email address")
password := fs.String("password", "", "password (omit to be prompted, recommended)") for i := 0; i < len(args); i++ {
if err := fs.Parse(args); err != nil { switch args[i] {
return 2 case "--display-name":
if i+1 < len(args) {
displayName = args[i+1]
i++
} }
rest := fs.Args() 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])
}
}
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user create <username> [--display-name NAME] [--email EMAIL] [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 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) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -98,29 +114,37 @@ func userList(dbase *db.DB, args []string) int {
} }
func userPasswd(dbase *db.DB, args []string) int { func userPasswd(dbase *db.DB, args []string) int {
fs := newFlagSet("nidusctl user passwd") var password string
password := fs.String("password", "", "new password (omit to be prompted, recommended)") var rest []string
if err := fs.Parse(args); err != nil {
return 2 for i := 0; i < len(args); i++ {
switch args[i] {
case "--password":
if i+1 < len(args) {
password = args[i+1]
i++
} }
rest := fs.Args() default:
rest = append(rest, args[i])
}
}
if len(rest) != 1 { if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]") fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd <username> [--password PASSWORD]")
return 2 return 2
} }
username := rest[0] username := rest[0]
pw := *password if password == "" {
if pw == "" {
var err error var err error
pw, err = promptPassword(username) password, err = promptPassword(username)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) fmt.Fprintf(os.Stderr, "error reading password: %v\n", err)
return 1 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) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }