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
+3 -13
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.ListenAndServe()
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
} else {
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
+44 -57
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,43 +70,41 @@ 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
nidusctl calendar create alice work nidusctl calendar create alice work
nidusctl calendar share alice work bob write nidusctl calendar share alice work bob write
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++
}
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 { 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++
}
default:
rest = append(rest, args[i])
}
} }
rest := fs.Args()
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
} }