From 58d74a29cddb17b04c563f162f6cfc77025f8065 Mon Sep 17 00:00:00 2001 From: arnef Date: Tue, 18 Aug 2026 21:01:28 +0200 Subject: [PATCH] Add nidusctl admin CLI for managing calendar/address-book shares - tools/nidusctl: subcommands `calendar {share,unshare,shares}` and `addressbook {share,unshare,shares}`, thin wrapper around internal/db. Warns (non-fatal) if owner/user isn't in config.yaml. - internal/db: add SharesOfAddressBook (owner-perspective query, mirrors SharesOfCalendar) needed by the CLI. - internal/db: db.Open now creates the parent data directory itself, so the CLI works standalone without requiring the server to have run first. - Update Makefile (build bin/nidusctl, new 'nidusctl' target), README and copilot-instructions with usage docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 8 ++ Makefile | 8 +- README.md | 24 +++- internal/db/db.go | 11 +- internal/db/shares.go | 27 ++++ tools/nidusctl/main.go | 221 ++++++++++++++++++++++++++++++++ tools/nidusctl/main_test.go | 196 ++++++++++++++++++++++++++++ 7 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 tools/nidusctl/main.go create mode 100644 tools/nidusctl/main_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c6a4232..8d78051 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -108,6 +108,14 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, reused, not per-request, or LOCK/UNLOCK state resets on every call. - `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd `) to generate bcrypt hashes for `config.yaml`. +- `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl + -config config.yaml ...`) + for managing `internal/db` sharing grants. It's a thin argv-parsing + wrapper around `db.DB`'s methods — no server interaction, no daemon, no + RPC; it just opens the same SQLite file the running server uses. Since + the caldav/carddav backends query the shares tables on every request + (no caching), changes take effect immediately without restarting the + server. ## Conventions diff --git a/Makefile b/Makefile index 5108638..6ea5e0e 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: build run test tidy lint docker hash-password +.PHONY: build run test tidy lint docker hash-password nidusctl ## build: compile the binary build: go build -o bin/davserver ./cmd/server + go build -o bin/nidusctl ./tools/nidusctl ## run: run the server locally run: build @@ -32,3 +33,8 @@ compose: ## Usage: make hash-password PWD=mysecret hash-password: @go run -v ./tools/hashpwd $(PWD) + +## nidusctl: build and run the sharing-grant admin CLI +## Usage: make nidusctl ARGS="calendar share alice work bob write" +nidusctl: build + ./bin/nidusctl -config config.yaml $(ARGS) diff --git a/README.md b/README.md index 665f109..3a6f8c1 100644 --- a/README.md +++ b/README.md @@ -146,11 +146,24 @@ grantee's own home-set alongside their own calendars — no separate account or extra client configuration needed. Sharing grants are stored in a small SQLite database at -`/nidus.db` (not in `config.yaml`). There's no CLI or web UI for -managing shares yet — the initial backend groundwork lives in -`internal/db` (see `ShareCalendar`, `UnshareCalendar`, -`ShareAddressBook`, `UnshareAddressBook`), which a future admin CLI or web -UI will call into. +`/nidus.db` (not in `config.yaml`) and managed with the +`nidusctl` CLI (there's no web UI yet): + +```bash +# Give bob write access to alice's "work" calendar +go run ./tools/nidusctl -config config.yaml calendar share alice work bob write + +# List everyone alice's "work" calendar is shared with +go run ./tools/nidusctl -config config.yaml calendar shares alice work + +# Revoke access +go run ./tools/nidusctl -config config.yaml calendar unshare alice work bob + +# Address books work the same way, using "addressbook" instead of "calendar" +go run ./tools/nidusctl -config config.yaml addressbook share alice contacts bob read +``` + +Or via `make`: `make nidusctl ARGS="calendar share alice work bob write"`. A calendar that `alice` shares with `bob` appears in bob's calendar home-set as `/cal/home/alice~work/` (i.e. `~`) — the @@ -237,6 +250,7 @@ caldav-server/ │ ├── store/ # filesystem storage layer │ └── webdav/ # WebDAV file handler ├── tools/hashpwd/ # bcrypt password hasher CLI +├── tools/nidusctl/ # sharing-grant admin CLI ├── config.example.yaml # sample configuration (copy to config.yaml) ├── Dockerfile ├── docker-compose.yaml diff --git a/internal/db/db.go b/internal/db/db.go index f81de5d..04699c2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -8,6 +8,8 @@ package db import ( "database/sql" "fmt" + "os" + "path/filepath" _ "modernc.org/sqlite" ) @@ -19,8 +21,15 @@ type DB struct { } // Open opens (creating if necessary) the SQLite database at path and runs -// schema migrations. +// schema migrations. The parent directory of path is created if it +// doesn't already exist. func Open(path string) (*DB, error) { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating database directory %q: %w", dir, err) + } + } + conn, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("opening database %q: %w", path, err) diff --git a/internal/db/shares.go b/internal/db/shares.go index a883540..bd8a107 100644 --- a/internal/db/shares.go +++ b/internal/db/shares.go @@ -178,6 +178,33 @@ func (d *DB) UnshareAddressBook(owner, bookName, sharedWith string) error { return nil } +// SharesOfAddressBook lists everyone owner's address book bookName has +// been shared with. +func (d *DB) SharesOfAddressBook(owner, bookName string) ([]AddressBookShare, error) { + rows, err := d.conn.Query(` + SELECT owner, addressbook_name, shared_with, permission + FROM addressbook_shares + WHERE owner = ? AND addressbook_name = ? + ORDER BY shared_with`, + owner, bookName) + if err != nil { + return nil, fmt.Errorf("listing address book shares: %w", err) + } + defer rows.Close() + + var shares []AddressBookShare + for rows.Next() { + var s AddressBookShare + var perm string + if err := rows.Scan(&s.Owner, &s.AddressBookName, &s.SharedWith, &perm); err != nil { + return nil, fmt.Errorf("scanning address book share: %w", err) + } + s.Permission = Permission(perm) + shares = append(shares, s) + } + return shares, rows.Err() +} + // AddressBooksSharedWith lists all address books (from any owner) that // have been shared with user. func (d *DB) AddressBooksSharedWith(user string) ([]AddressBookShare, error) { diff --git a/tools/nidusctl/main.go b/tools/nidusctl/main.go new file mode 100644 index 0000000..a431664 --- /dev/null +++ b/tools/nidusctl/main.go @@ -0,0 +1,221 @@ +// Command nidusctl is a small administrative CLI for the nidus DAV +// server. It currently manages calendar and address-book sharing grants +// stored in the SQLite database at /nidus.db; it doesn't talk +// to a running server, so the server should be restarted (or, in the +// future, will pick up changes automatically) after granting/revoking +// shares — the caldav/carddav backends read shares on every request, so +// no cache invalidation is needed, only an already-open DB connection. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/db" +) + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + fs := flag.NewFlagSet("nidusctl", flag.ContinueOnError) + cfgPath := fs.String("config", "config.yaml", "path to configuration file") + if err := fs.Parse(args); err != nil { + return 2 + } + rest := fs.Args() + if len(rest) < 1 { + usage() + return 2 + } + + cfg, err := config.Load(*cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) + return 1 + } + + dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db") + dbase, err := db.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening database %q: %v\n", dbPath, err) + return 1 + } + defer dbase.Close() + + switch rest[0] { + case "calendar", "cal": + return runCalendar(cfg, dbase, rest[1:]) + case "addressbook", "card": + return runAddressBook(cfg, dbase, rest[1:]) + case "help", "-h", "--help": + usage() + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n", rest[0]) + usage() + return 2 + } +} + +func usage() { + fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server sharing grants + +Usage: + nidusctl [-config config.yaml] calendar share + nidusctl [-config config.yaml] calendar unshare + nidusctl [-config config.yaml] calendar shares + nidusctl [-config config.yaml] addressbook share + nidusctl [-config config.yaml] addressbook unshare + nidusctl [-config config.yaml] addressbook shares + +Examples: + nidusctl calendar share alice work bob write + nidusctl calendar shares alice work + nidusctl calendar unshare alice work bob +`) +} + +// userExists reports whether username is a configured user, printing a +// warning (not a hard error) if not — the share is still recorded, since +// config.yaml and the share database are independent sources of truth and +// a user added after the fact shouldn't require re-running share commands. +func warnIfUnknownUser(cfg *config.Config, username string) { + if _, ok := cfg.Users[username]; !ok { + fmt.Fprintf(os.Stderr, "warning: %q is not a user in config.yaml (continuing anyway)\n", username) + } +} + +func runCalendar(cfg *config.Config, dbase *db.DB, args []string) int { + if len(args) < 1 { + usage() + return 2 + } + switch args[0] { + case "share": + if len(args) != 5 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar share ") + return 2 + } + owner, calName, user, permStr := args[1], args[2], args[3], args[4] + perm := db.Permission(permStr) + if perm != db.PermRead && perm != db.PermWrite { + fmt.Fprintf(os.Stderr, "invalid permission %q: must be %q or %q\n", permStr, db.PermRead, db.PermWrite) + return 2 + } + warnIfUnknownUser(cfg, owner) + warnIfUnknownUser(cfg, user) + if err := dbase.ShareCalendar(owner, calName, user, perm); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("shared %s's calendar %q with %s (%s)\n", owner, calName, user, perm) + return 0 + + case "unshare": + if len(args) != 4 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar unshare ") + return 2 + } + owner, calName, user := args[1], args[2], args[3] + if err := dbase.UnshareCalendar(owner, calName, user); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("unshared %s's calendar %q from %s\n", owner, calName, user) + return 0 + + case "shares": + if len(args) != 3 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar shares ") + return 2 + } + owner, calName := args[1], args[2] + shares, err := dbase.SharesOfCalendar(owner, calName) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if len(shares) == 0 { + fmt.Printf("%s's calendar %q is not shared with anyone\n", owner, calName) + return 0 + } + for _, s := range shares { + fmt.Printf("%s\t%s\n", s.SharedWith, s.Permission) + } + return 0 + + default: + fmt.Fprintf(os.Stderr, "unknown calendar subcommand %q\n", args[0]) + return 2 + } +} + +func runAddressBook(cfg *config.Config, dbase *db.DB, args []string) int { + if len(args) < 1 { + usage() + return 2 + } + switch args[0] { + case "share": + if len(args) != 5 { + fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook share ") + return 2 + } + owner, bookName, user, permStr := args[1], args[2], args[3], args[4] + perm := db.Permission(permStr) + if perm != db.PermRead && perm != db.PermWrite { + fmt.Fprintf(os.Stderr, "invalid permission %q: must be %q or %q\n", permStr, db.PermRead, db.PermWrite) + return 2 + } + warnIfUnknownUser(cfg, owner) + warnIfUnknownUser(cfg, user) + if err := dbase.ShareAddressBook(owner, bookName, user, perm); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("shared %s's address book %q with %s (%s)\n", owner, bookName, user, perm) + return 0 + + case "unshare": + if len(args) != 4 { + fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook unshare ") + return 2 + } + owner, bookName, user := args[1], args[2], args[3] + if err := dbase.UnshareAddressBook(owner, bookName, user); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("unshared %s's address book %q from %s\n", owner, bookName, user) + return 0 + + case "shares": + if len(args) != 3 { + fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook shares ") + return 2 + } + owner, bookName := args[1], args[2] + shares, err := dbase.SharesOfAddressBook(owner, bookName) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if len(shares) == 0 { + fmt.Printf("%s's address book %q is not shared with anyone\n", owner, bookName) + return 0 + } + for _, s := range shares { + fmt.Printf("%s\t%s\n", s.SharedWith, s.Permission) + } + return 0 + + default: + fmt.Fprintf(os.Stderr, "unknown addressbook subcommand %q\n", args[0]) + return 2 + } +} diff --git a/tools/nidusctl/main_test.go b/tools/nidusctl/main_test.go new file mode 100644 index 0000000..4057f93 --- /dev/null +++ b/tools/nidusctl/main_test.go @@ -0,0 +1,196 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yourusername/caldav-server/internal/db" +) + +// writeTestConfig creates a minimal config.yaml in dir and returns its path. +func writeTestConfig(t *testing.T, dir string) string { + t.Helper() + cfgPath := filepath.Join(dir, "config.yaml") + dataDir := filepath.Join(dir, "data") + content := "storage:\n data_dir: " + dataDir + "\n" + + "users:\n" + + " alice:\n" + + " password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n" + + " bob:\n" + + " password: \"$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O\"\n" + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatalf("writing test config: %v", err) + } + return cfgPath +} + +// runCLI runs the CLI's run() function, capturing stdout/stderr, and +// returns (exit code, combined stdout+stderr). +func runCLI(t *testing.T, args ...string) (int, string) { + t.Helper() + + oldStdout, oldStderr := os.Stdout, os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + os.Stderr = w + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + }() + + code := run(args) + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + return code, buf.String() +} + +func TestCalendarShareUnshareLifecycle(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir) + + code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "write") + if code != 0 { + t.Fatalf("share exit code = %d, output: %s", code, out) + } + if !strings.Contains(out, "shared") { + t.Errorf("output = %q, want to contain 'shared'", out) + } + + code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") + if code != 0 { + t.Fatalf("shares exit code = %d, output: %s", code, out) + } + if !strings.Contains(out, "bob") || !strings.Contains(out, "write") { + t.Errorf("output = %q, want to contain bob/write", out) + } + + code, out = runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "work", "bob") + if code != 0 { + t.Fatalf("unshare exit code = %d, output: %s", code, out) + } + + code, out = runCLI(t, "-config", cfgPath, "calendar", "shares", "alice", "work") + if code != 0 { + t.Fatalf("shares (after unshare) exit code = %d, output: %s", code, out) + } + if !strings.Contains(out, "not shared with anyone") { + t.Errorf("output = %q, want 'not shared with anyone'", out) + } +} + +func TestCalendarShareInvalidPermission(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir) + + code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "bob", "admin") + if code != 2 { + t.Errorf("exit code = %d, want 2; output: %s", code, out) + } + if !strings.Contains(out, "invalid permission") { + t.Errorf("output = %q, want 'invalid permission'", out) + } +} + +func TestCalendarUnshareNotFound(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir) + + code, out := runCLI(t, "-config", cfgPath, "calendar", "unshare", "alice", "ghost", "bob") + if code != 1 { + t.Errorf("exit code = %d, want 1; output: %s", code, out) + } + if !strings.Contains(out, "not found") { + t.Errorf("output = %q, want 'not found'", out) + } +} + +func TestAddressBookShareUnshareLifecycle(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir) + + code, out := runCLI(t, "-config", cfgPath, "addressbook", "share", "alice", "contacts", "bob", "read") + if code != 0 { + t.Fatalf("share exit code = %d, output: %s", code, out) + } + + code, out = runCLI(t, "-config", cfgPath, "addressbook", "shares", "alice", "contacts") + if code != 0 { + t.Fatalf("shares exit code = %d, output: %s", code, out) + } + if !strings.Contains(out, "bob") || !strings.Contains(out, "read") { + t.Errorf("output = %q, want to contain bob/read", out) + } + + code, out = runCLI(t, "-config", cfgPath, "addressbook", "unshare", "alice", "contacts", "bob") + if code != 0 { + t.Fatalf("unshare exit code = %d, output: %s", code, out) + } +} + +func TestUnknownUserWarningDoesNotBlockShare(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir) + + code, out := runCLI(t, "-config", cfgPath, "calendar", "share", "alice", "work", "carol", "read") + if code != 0 { + t.Fatalf("exit code = %d, output: %s", code, out) + } + if !strings.Contains(out, "warning") || !strings.Contains(out, "carol") { + t.Errorf("output = %q, want a warning about unknown user carol", out) + } + if !strings.Contains(out, "shared") { + t.Errorf("output = %q, want the share to still succeed", out) + } +} + +func TestNoArgsShowsUsage(t *testing.T) { + dir := t.TempDir() + // No config needed since usage() is printed before config.Load for + // missing subcommands. + code, out := runCLI(t, "-config", filepath.Join(dir, "missing.yaml")) + if code != 2 { + t.Errorf("exit code = %d, want 2", code) + } + if !strings.Contains(out, "Usage") { + t.Errorf("output = %q, want usage text", out) + } +} + +func TestUnknownCommand(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestConfig(t, dir) + + code, out := runCLI(t, "-config", cfgPath, "bogus") + if code != 2 { + t.Errorf("exit code = %d, want 2", code) + } + if !strings.Contains(out, "unknown command") { + t.Errorf("output = %q, want 'unknown command'", out) + } +} + +// Sanity check that the CLI and internal/db agree on ErrShareNotFound +// being surfaced (not swallowed) through the exit code / error message. +func TestUnshareErrorIsShareNotFound(t *testing.T) { + dir := t.TempDir() + dbase, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + defer dbase.Close() + + err = dbase.UnshareCalendar("alice", "work", "bob") + if !errors.Is(err, db.ErrShareNotFound) { + t.Errorf("err = %v, want ErrShareNotFound", err) + } +}