diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7a838a9..871d9bd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,20 +29,22 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, ## Architecture - `cmd/server/main.go` — entrypoint. Loads config, builds the `slog.Logger`, - constructs the `store.Store`, pre-creates each user's configured - calendars/address-books as collections, wires up `auth.Middleware`, and - builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`, + constructs the `store.Store` and `db.DB`, lists all users/calendars/ + address-books from the DB (`dbase.ListUsers`/`ListCalendars`/ + `ListAddressBooks`) to pre-create their collections on disk, warns if + zero users exist (`nidusctl user create ...`), wires up `auth.Middleware`, + and builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`, `/files/`, `/.well-known/{caldav,carddav}`, `/healthz` (unauthenticated), and `/` (welcome page on GET/HEAD only; any other method — e.g. a WebDAV client pointed at the wrong URL — gets `405` instead of a misleading `200`). - `internal/config` — YAML config loading (`config.Load`), defaults - (`applyDefaults`), and validation (`validate`). `Config.Users` is a - `map[string]UserConfig` keyed by username; each user has a bcrypt - `Password`, `Calendars`, and `AddressBooks` lists that seed collection - names. + (`applyDefaults`), and validation (`validate`). Holds only server/auth/ + storage/logging/TLS settings — **no user, calendar, or address-book + data**; all of that lives in `internal/db` now (see below). - `internal/auth` — HTTP Basic Auth middleware (`auth.Middleware.Wrap`). - Validates credentials against `cfg.Users` via bcrypt, then stores a + Takes a `*db.DB` and validates credentials via `dbase.GetUser` + + `dbase.VerifyPassword` (bcrypt), then stores a `*Principal{Username, DisplayName, Email}` in the request context. Downstream code retrieves it with `auth.FromContext(ctx)` — every backend method needs this and returns `webdav.NewHTTPError(http.StatusUnauthorized, ...)` @@ -86,8 +88,9 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, (`QueryCalendarObjects`) currently list all objects and filter in-memory via `caldav.Filter` — fine for small collections, not optimized for scale. - **Sharing**: both backends take an `*db.DB` (may be `nil` to disable - sharing). A calendar/address book shared with a user is exposed under + **Sharing**: both backends take an `*db.DB` (required — used for both + the base `ListCalendars`/`ListAddressBooks`/`Create*`/`Delete*` + operations and sharing). A calendar/address book shared with a user is exposed under the synthetic local name `~` (see `sharedNameSep`, `sharedCalendarName`/`sharedBookName`) in that user's own home-set — `resolveCalendar`/`resolveBook` split the local name back into @@ -101,10 +104,20 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, `modernc.org/sqlite` (pure Go, no CGO) at `/nidus.db`. Only one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a single writer; this is intentionally simple and not meant to scale to - heavy concurrent write load — fine for sharing grants (and future user - management), not a general-purpose data store (that's what - `internal/store` is for). Schema lives in `migrate()`; there's no + heavy concurrent write load. Schema lives in `migrate()`; there's no migration framework, just idempotent `CREATE TABLE IF NOT EXISTS`. + Foreign keys are enabled per-connection (`?_pragma=foreign_keys(1)` in + the DSN). **This is now the single source of truth for users, + calendars, and address books** (`internal/db/users.go`): + `CreateUser`/`SetPassword`/`DeleteUser`/`GetUser`/`VerifyPassword`/ + `ListUsers`, and `CreateCalendar`/`DeleteCalendar`/`ListCalendars`, + `CreateAddressBook`/`DeleteAddressBook`/`ListAddressBooks`. `users` is + the parent table; `calendars`/`addressbooks` cascade-delete via FK on + `DeleteUser`; `calendar_shares`/`addressbook_shares`/`web_sessions` + reference usernames as plain strings (no FK) so `DeleteUser` explicitly + cleans those up in a transaction. `modernc.org/sqlite` has no typed + unique-constraint error, so `isUniqueConstraintErr()` string-matches the + driver's error message. - `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`, mounted at the single fixed URL `/files/` for all users (no username in the path either). `NewHandler` caches one `*xwebdav.Handler` per @@ -113,23 +126,27 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, `LockSystem` — the handler (and its lock table) must be created once and reused, not per-request, or LOCK/UNLOCK state resets on every call. - `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd `) to - generate bcrypt hashes for `config.yaml`. + generate bcrypt hashes for ad-hoc testing (no longer needed for normal + user setup — see `nidusctl user create` below). - `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl - -config config.yaml ...`) - 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. + -config config.yaml ...`) — the only way to + create/delete users, calendars, and address books, plus manage sharing + grants (`calendar|addressbook share|unshare|shares`). It's a thin + argv-parsing wrapper around `db.DB`'s methods (`tools/nidusctl/main.go` + routes subcommands, `tools/nidusctl/users.go` implements `user + create/delete/list/passwd` with interactive masked password prompting + via `golang.org/x/term`, falling back to a plain stdin read when not a + TTY) — no server interaction, no daemon, no RPC; it just opens the same + SQLite file the running server uses. Changes take effect immediately + without restarting the server since nothing is cached. - `internal/web` — the web UI, mounted at `/web/` in `cmd/server/main.go` (`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st, dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s Basic Auth: logins go through - `/web/login` (username/password checked against `cfg.Users` the same way - Basic Auth does, via bcrypt) and issue an opaque random session token + `/web/login` (username/password checked against the DB the same way + Basic Auth does, via `dbase.VerifyPassword`) and issue an opaque random session token stored in the `web_sessions` SQLite table (`db.CreateSession`/ `SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an `HttpOnly` cookie (`sessionCookieName` in `internal/web/session.go`). @@ -138,7 +155,15 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, `internal/web/dashboard.go` renders the logged-in user's own calendars/address books plus who they're shared with (`SharesOfCalendar`/`SharesOfAddressBook`) and what's shared with them - (`CalendarsSharedWith`/`AddressBooksSharedWith`). `internal/web/shares.go` + (`CalendarsSharedWith`/`AddressBooksSharedWith`); `resourceCards(username)` + is the shared helper (also used by `internal/web/resources.go`, see + below) that builds the list of `templates.ResourceCard`s from the DB. + `internal/web/resources.go` handles POST (create) and DELETE (delete) at + `/web/resources/{calendar,addressbook}`, validating names against + `resourceNameRe` (`^[a-zA-Z0-9_-]{1,64}$`) and re-rendering the whole + `#resources` list (`templates.ResourceList`) since the *set* of cards + changes (unlike a share update, which only touches one card). + `internal/web/shares.go` handles POST (create/update share) and DELETE (revoke) at `/web/shares/{calendar,addressbook}`, re-rendering just the affected resource card for htmx's `hx-swap="outerHTML"`; it always checks diff --git a/README.md b/README.md index 9acab41..2a2b661 100644 --- a/README.md +++ b/README.md @@ -30,38 +30,20 @@ A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go. go mod tidy ``` -### 2. Generate password hashes - -```bash -go run ./tools/hashpwd mysecretpassword -# Outputs: $2b$12$... -``` - -### 3. Create your `config.yaml` +### 2. Create your `config.yaml` Copy the example config and edit it — `config.yaml` is git-ignored so your -real credentials/domain never get committed: +real settings never get committed: ```bash cp config.example.yaml config.yaml ``` -Replace the placeholder hashes with your real bcrypt hashes: +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). -```yaml -users: - alice: - password: "$2b$12$" - display_name: "Alice Smith" - email: "alice@example.com" - calendars: - - personal - - work - address_books: - - contacts -``` - -### 4. Run the server +### 3. Run the server ```bash make run @@ -71,6 +53,20 @@ go run ./cmd/server -config config.yaml The server starts at **http://localhost:8080**. +### 4. Create a user and their resources + +```bash +go run ./tools/nidusctl -config config.yaml user create alice \ + --display-name "Alice Smith" --email alice@example.com +# (prompts for a password; use --password to skip the prompt, e.g. in scripts) + +go run ./tools/nidusctl -config config.yaml calendar create alice personal +go run ./tools/nidusctl -config config.yaml addressbook create alice contacts +``` + +Users can also be created/removed via the web UI (`/web/`) once logged in +as an existing user — see **Web UI** below. + --- ## Docker @@ -256,18 +252,49 @@ tls: enabled: false cert_file: "" key_file: "" - -users: - : - password: "" - display_name: "Full Name" - email: "user@example.com" - calendars: # pre-created calendar names - - personal - address_books: # pre-created address book names - - contacts ``` +Users, calendars, and address books are managed via `nidusctl`, not +`config.yaml` — see **Managing users** below. + +## Managing users + +All user/calendar/address-book management is done with `nidusctl` (or the +web UI). Nothing is stored in `config.yaml` anymore. + +```bash +# Users +nidusctl user create [--display-name NAME] [--email EMAIL] [--password PW] +nidusctl user delete +nidusctl user list +nidusctl user passwd [--password PW] + +# Calendars +nidusctl calendar create +nidusctl calendar delete +nidusctl calendar list +nidusctl calendar share +nidusctl calendar unshare +nidusctl calendar shares + +# Address books +nidusctl addressbook create +nidusctl addressbook delete +nidusctl addressbook list +nidusctl addressbook share +nidusctl addressbook unshare +nidusctl addressbook shares +``` + +Passwords are prompted for interactively (masked, double-entry) when +`--password` is omitted. The web UI (`/web/`) also lets a logged-in user +create/delete their own calendars and address books from the dashboard. + +> **Upgrading from an older version?** The `users:` section in +> `config.yaml` is no longer read. Recreate your users with +> `nidusctl user create` (and their calendars/address books) — there is no +> automatic migration from the old config format. + --- ## Project layout diff --git a/cmd/server/main.go b/cmd/server/main.go index f2cdcfd..6949ea0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -51,7 +51,7 @@ func main() { os.Exit(1) } - // ---- Database (calendar/address-book sharing, future user mgmt) ---- + // ---- Database (users, calendars, address books, sharing) ---- dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db") dbase, err := db.Open(dbPath) if err != nil { @@ -60,22 +60,46 @@ func main() { } defer dbase.Close() - // Pre-create default collections for each user - for username, user := range cfg.Users { - for _, cal := range user.Calendars { - if err := st.EnsureCollection(username, "cal-"+cal); err != nil { - logger.Warn("creating calendar collection", "user", username, "cal", cal, "error", err) + if n, err := dbase.UserCount(); err != nil { + logger.Error("counting users", "error", err) + os.Exit(1) + } else if n == 0 { + logger.Warn("no users exist yet — create one with: nidusctl user create ") + } + + // Pre-create on-disk collections for every registered calendar/address + // book, in case they were added via nidusctl/web UI while the server + // wasn't running. + users, err := dbase.ListUsers() + if err != nil { + logger.Error("listing users", "error", err) + os.Exit(1) + } + for _, user := range users { + cals, err := dbase.ListCalendars(user.Username) + if err != nil { + logger.Warn("listing calendars", "user", user.Username, "error", err) + continue + } + for _, cal := range cals { + if err := st.EnsureCollection(user.Username, "cal-"+cal); err != nil { + logger.Warn("creating calendar collection", "user", user.Username, "cal", cal, "error", err) } } - for _, book := range user.AddressBooks { - if err := st.EnsureCollection(username, "card-"+book); err != nil { - logger.Warn("creating address book collection", "user", username, "book", book, "error", err) + books, err := dbase.ListAddressBooks(user.Username) + if err != nil { + logger.Warn("listing address books", "user", user.Username, "error", err) + continue + } + for _, book := range books { + if err := st.EnsureCollection(user.Username, "card-"+book); err != nil { + logger.Warn("creating address book collection", "user", user.Username, "book", book, "error", err) } } } // ---- Middleware ---- - authMw := auth.NewMiddleware(cfg, logger) + authMw := auth.NewMiddleware(cfg, dbase, logger) // ---- Handlers ---- calHandler := caldav.NewHandler(cfg, st, dbase, logger) diff --git a/config.example.yaml b/config.example.yaml index 9e4c66f..ae77f65 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -19,16 +19,13 @@ tls: # cert_file: "/etc/ssl/certs/dav.crt" # key_file: "/etc/ssl/private/dav.key" -# Users: passwords must be bcrypt hashes. -# Generate with: htpasswd -nB alice -# or in Go: bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost) -users: - alice: - password: "$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O" # mysecretpassword - display_name: "Alice Smith" - email: "alice@example.com" - calendars: - - personal - - work - address_books: - - contacts +# Users, calendars, and address books are no longer configured here — they +# are stored in the database (/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 +# nidusctl calendar create alice personal +# nidusctl calendar create alice work +# nidusctl addressbook create alice contacts +# +# Run `nidusctl help` for the full command list. diff --git a/go.mod b/go.mod index 33d7158..fcc29fe 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 6c94a25..05552fd 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 858e8d5..fc0a914 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/yourusername/caldav-server/internal/config" - "golang.org/x/crypto/bcrypt" + "github.com/yourusername/caldav-server/internal/db" ) type contextKey string @@ -16,11 +16,12 @@ const userContextKey contextKey = "authenticated_user" // Middleware wraps an http.Handler with HTTP Basic Auth enforcement. type Middleware struct { cfg *config.Config + dbase *db.DB logger *slog.Logger } -func NewMiddleware(cfg *config.Config, logger *slog.Logger) *Middleware { - return &Middleware{cfg: cfg, logger: logger} +func NewMiddleware(cfg *config.Config, dbase *db.DB, logger *slog.Logger) *Middleware { + return &Middleware{cfg: cfg, dbase: dbase, logger: logger} } // Wrap returns an http.Handler that requires valid Basic Auth credentials @@ -65,20 +66,16 @@ func (m *Middleware) challenge(w http.ResponseWriter) { _, _ = w.Write([]byte("Unauthorized")) } -// authenticate validates username/password against config. -func (m *Middleware) authenticate(username, password string) (*config.UserConfig, error) { - user, ok := m.cfg.Users[username] - if !ok { - // constant-time comparison to avoid timing attacks - _ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalid"), []byte(password)) +// authenticate validates username/password against the database. +func (m *Middleware) authenticate(username, password string) (*db.User, error) { + user, err := m.dbase.GetUser(username) + if err != nil { return nil, errUnauthorized } - - if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + if !m.dbase.VerifyPassword(username, password) { return nil, errUnauthorized } - - return &user, nil + return user, nil } // Principal holds the authenticated user's identity. diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index dc5faf3..191dfec 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -73,13 +73,13 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } - user, ok := b.cfg.Users[p.Username] - if !ok { - return nil, fmt.Errorf("user not found") + names, err := b.dbase.ListCalendars(p.Username) + if err != nil { + return nil, fmt.Errorf("listing calendars: %w", err) } var cals []caldav.Calendar - for _, name := range user.Calendars { + for _, name := range names { if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil { b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err) continue @@ -87,10 +87,10 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) cals = append(cals, b.calendarMeta(p.Username, name, name)) } - // Also include any extra calendars that exist on disk but aren't in config + // Also include any extra calendars that exist on disk but aren't registered disk, _ := b.store.ListCollections(p.Username) configured := make(map[string]bool) - for _, n := range user.Calendars { + for _, n := range names { configured["cal-"+n] = true } for _, dir := range disk { @@ -202,6 +202,9 @@ func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) // existing calendar is done via ShareCalendar, not by creating one // directly in someone else's name. name := path.Base(strings.TrimSuffix(calendar.Path, "/")) + if err := b.dbase.CreateCalendar(p.Username, name); err != nil && err != db.ErrResourceExists { + return fmt.Errorf("registering calendar: %w", err) + } return b.store.EnsureCollection(p.Username, "cal-"+name) } @@ -214,6 +217,9 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error { if err != nil { return err } + if err := b.dbase.DeleteCalendar(owner, realName); err != nil && err != db.ErrResourceNotFound { + return fmt.Errorf("unregistering calendar: %w", err) + } return b.store.DeleteCollection(owner, "cal-"+realName) } diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go index 9558fc8..262f553 100644 --- a/internal/caldav/backend_test.go +++ b/internal/caldav/backend_test.go @@ -30,11 +30,18 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) { } t.Cleanup(func() { dbase.Close() }) - cfg := &config.Config{ - Users: map[string]config.UserConfig{ - "alice": {Calendars: []string{"work"}}, - "bob": {Calendars: []string{"personal"}}, - }, + cfg := &config.Config{} + if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { + t.Fatalf("CreateUser alice: %v", err) + } + if err := dbase.CreateUser("bob", "pw", "", ""); err != nil { + t.Fatalf("CreateUser bob: %v", err) + } + if err := dbase.CreateCalendar("alice", "work"); err != nil { + t.Fatalf("CreateCalendar alice/work: %v", err) + } + if err := dbase.CreateCalendar("bob", "personal"); err != nil { + t.Fatalf("CreateCalendar bob/personal: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return NewBackend(cfg, st, dbase, logger), dbase diff --git a/internal/carddav/backend.go b/internal/carddav/backend.go index df18877..b6b30c2 100644 --- a/internal/carddav/backend.go +++ b/internal/carddav/backend.go @@ -67,13 +67,13 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } - user, ok := b.cfg.Users[p.Username] - if !ok { - return nil, fmt.Errorf("user not found") + names, err := b.dbase.ListAddressBooks(p.Username) + if err != nil { + return nil, fmt.Errorf("listing address books: %w", err) } var books []carddav.AddressBook - for _, name := range user.AddressBooks { + for _, name := range names { if err := b.store.EnsureCollection(p.Username, "card-"+name); err != nil { b.logger.Warn("ensuring address book directory", "book", name, "error", err) continue @@ -81,10 +81,10 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, books = append(books, b.bookMeta(p.Username, name, name)) } - // Also include extra books that exist on disk + // Also include extra books that exist on disk but aren't registered disk, _ := b.store.ListCollections(p.Username) configured := make(map[string]bool) - for _, n := range user.AddressBooks { + for _, n := range names { configured["card-"+n] = true } for _, dir := range disk { @@ -191,6 +191,9 @@ func (b *Backend) CreateAddressBook(ctx context.Context, book *carddav.AddressBo return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } name := path.Base(strings.TrimSuffix(book.Path, "/")) + if err := b.dbase.CreateAddressBook(p.Username, name); err != nil && err != db.ErrResourceExists { + return fmt.Errorf("registering address book: %w", err) + } return b.store.EnsureCollection(p.Username, "card-"+name) } @@ -203,6 +206,9 @@ func (b *Backend) DeleteAddressBook(ctx context.Context, bookPath string) error if err != nil { return err } + if err := b.dbase.DeleteAddressBook(owner, realName); err != nil && err != db.ErrResourceNotFound { + return fmt.Errorf("unregistering address book: %w", err) + } return b.store.DeleteCollection(owner, "card-"+realName) } diff --git a/internal/carddav/backend_test.go b/internal/carddav/backend_test.go index 7b6f579..8b8c035 100644 --- a/internal/carddav/backend_test.go +++ b/internal/carddav/backend_test.go @@ -29,11 +29,18 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) { } t.Cleanup(func() { dbase.Close() }) - cfg := &config.Config{ - Users: map[string]config.UserConfig{ - "alice": {AddressBooks: []string{"contacts"}}, - "bob": {AddressBooks: []string{"personal"}}, - }, + cfg := &config.Config{} + if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { + t.Fatalf("CreateUser alice: %v", err) + } + if err := dbase.CreateUser("bob", "pw", "", ""); err != nil { + t.Fatalf("CreateUser bob: %v", err) + } + if err := dbase.CreateAddressBook("alice", "contacts"); err != nil { + t.Fatalf("CreateAddressBook alice/contacts: %v", err) + } + if err := dbase.CreateAddressBook("bob", "personal"); err != nil { + t.Fatalf("CreateAddressBook bob/personal: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return NewBackend(cfg, st, dbase, logger), dbase diff --git a/internal/config/config.go b/internal/config/config.go index 26ee678..6287303 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,12 +9,11 @@ import ( // Config is the top-level server configuration. type Config struct { - Server ServerConfig `yaml:"server"` - Auth AuthConfig `yaml:"auth"` - Storage StorageConfig `yaml:"storage"` - Users map[string]UserConfig `yaml:"users"` - TLS TLSConfig `yaml:"tls"` - Logging LoggingConfig `yaml:"logging"` + Server ServerConfig `yaml:"server"` + Auth AuthConfig `yaml:"auth"` + Storage StorageConfig `yaml:"storage"` + TLS TLSConfig `yaml:"tls"` + Logging LoggingConfig `yaml:"logging"` } type ServerConfig struct { @@ -34,15 +33,6 @@ type StorageConfig struct { DataDir string `yaml:"data_dir"` } -type UserConfig struct { - // bcrypt-hashed password (use `htpasswd -nB `) - Password string `yaml:"password"` - DisplayName string `yaml:"display_name"` - Email string `yaml:"email"` - Calendars []string `yaml:"calendars"` - AddressBooks []string `yaml:"address_books"` -} - type TLSConfig struct { Enabled bool `yaml:"enabled"` CertFile string `yaml:"cert_file"` @@ -105,8 +95,5 @@ func (c *Config) validate() error { return fmt.Errorf("tls.cert_file and tls.key_file are required when tls is enabled") } } - if len(c.Users) == 0 { - return fmt.Errorf("at least one user must be configured") - } return nil } diff --git a/internal/db/db.go b/internal/db/db.go index 5329bd6..457c007 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -30,7 +30,7 @@ func Open(path string) (*DB, error) { } } - conn, err := sql.Open("sqlite", path) + conn, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)") if err != nil { return nil, fmt.Errorf("opening database %q: %w", path, err) } @@ -53,6 +53,28 @@ func (d *DB) Close() error { func (d *DB) migrate() error { const schema = ` +CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS calendars ( + owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (owner, name) +); + +CREATE TABLE IF NOT EXISTS addressbooks ( + owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (owner, name) +); + CREATE TABLE IF NOT EXISTS calendar_shares ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, diff --git a/internal/db/users.go b/internal/db/users.go new file mode 100644 index 0000000..e72e255 --- /dev/null +++ b/internal/db/users.go @@ -0,0 +1,291 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// ErrUserNotFound is returned when a username doesn't exist. +var ErrUserNotFound = errors.New("user not found") + +// ErrUserExists is returned when trying to create a user that already +// exists. +var ErrUserExists = errors.New("user already exists") + +// ErrResourceExists is returned when creating a calendar/address book that +// already exists for that owner. +var ErrResourceExists = errors.New("resource already exists") + +// ErrResourceNotFound is returned when deleting a calendar/address book +// that doesn't exist. +var ErrResourceNotFound = errors.New("resource not found") + +// User is an account stored in the database. +type User struct { + Username string + PasswordHash string + DisplayName string + Email string +} + +// CreateUser adds a new account with the given (already plaintext) +// password, which is bcrypt-hashed before being stored. Returns +// ErrUserExists if the username is already taken. +func (d *DB) CreateUser(username, password, displayName, email string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + _, err = d.conn.Exec( + `INSERT INTO users (username, password_hash, display_name, email) VALUES (?, ?, ?, ?)`, + username, string(hash), displayName, email, + ) + if err != nil { + if isUniqueConstraintErr(err) { + return ErrUserExists + } + return fmt.Errorf("creating user: %w", err) + } + return nil +} + +// SetPassword updates username's password hash. Returns ErrUserNotFound +// if the user doesn't exist. +func (d *DB) SetPassword(username, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + res, err := d.conn.Exec(`UPDATE users SET password_hash = ? WHERE username = ?`, string(hash), username) + if err != nil { + return fmt.Errorf("setting password: %w", err) + } + return requireRowsAffected(res, ErrUserNotFound) +} + +// DeleteUser removes username along with all of its calendars, address +// books, and sharing grants (calendars/addressbooks cascade via foreign +// key; shares are cleaned up explicitly since they reference usernames as +// plain text, not a foreign key, on both sides of the grant). +func (d *DB) DeleteUser(username string) error { + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("deleting user: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec(`DELETE FROM users WHERE username = ?`, username) + if err != nil { + return fmt.Errorf("deleting user: %w", err) + } + if err := requireRowsAffected(res, ErrUserNotFound); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM calendar_shares WHERE owner = ? OR shared_with = ?`, username, username); err != nil { + return fmt.Errorf("deleting user's calendar shares: %w", err) + } + if _, err := tx.Exec(`DELETE FROM addressbook_shares WHERE owner = ? OR shared_with = ?`, username, username); err != nil { + return fmt.Errorf("deleting user's address book shares: %w", err) + } + if _, err := tx.Exec(`DELETE FROM web_sessions WHERE username = ?`, username); err != nil { + return fmt.Errorf("deleting user's sessions: %w", err) + } + return tx.Commit() +} + +// GetUser looks up a user by username. Returns ErrUserNotFound if it +// doesn't exist. +func (d *DB) GetUser(username string) (*User, error) { + row := d.conn.QueryRow( + `SELECT username, password_hash, display_name, email FROM users WHERE username = ?`, + username, + ) + var u User + if err := row.Scan(&u.Username, &u.PasswordHash, &u.DisplayName, &u.Email); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("looking up user: %w", err) + } + return &u, nil +} + +// VerifyPassword returns true if password matches username's stored hash. +// It also returns false (without distinguishing why) if the user doesn't +// exist, running a dummy bcrypt comparison first to keep the timing +// consistent regardless of whether the account exists. +func (d *DB) VerifyPassword(username, password string) bool { + u, err := d.GetUser(username) + if err != nil { + _ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalidinvalidinvalidinvalidinvalidinval"), []byte(password)) + return false + } + return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil +} + +// ListUsers returns all usernames, sorted. +func (d *DB) ListUsers() ([]User, error) { + rows, err := d.conn.Query(`SELECT username, password_hash, display_name, email FROM users ORDER BY username`) + if err != nil { + return nil, fmt.Errorf("listing users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.Username, &u.PasswordHash, &u.DisplayName, &u.Email); err != nil { + return nil, fmt.Errorf("scanning user: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + +// UserCount returns the number of users in the database (used to detect a +// fresh install so config.yaml's legacy `users:` section, if present, can +// be imported once). +func (d *DB) UserCount() (int, error) { + var n int + err := d.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + if err != nil { + return 0, fmt.Errorf("counting users: %w", err) + } + return n, nil +} + +// -------- calendars -------- + +// CreateCalendar registers a new calendar owned by owner. Returns +// ErrResourceExists if it already exists. +func (d *DB) CreateCalendar(owner, name string) error { + _, err := d.conn.Exec(`INSERT INTO calendars (owner, name) VALUES (?, ?)`, owner, name) + if err != nil { + if isUniqueConstraintErr(err) { + return ErrResourceExists + } + return fmt.Errorf("creating calendar: %w", err) + } + return nil +} + +// DeleteCalendar removes a calendar registration (not the underlying +// files/objects — callers are responsible for also removing those via +// store.Store). Returns ErrResourceNotFound if it doesn't exist. Any +// sharing grants for it are removed as well. +func (d *DB) DeleteCalendar(owner, name string) error { + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("deleting calendar: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec(`DELETE FROM calendars WHERE owner = ? AND name = ?`, owner, name) + if err != nil { + return fmt.Errorf("deleting calendar: %w", err) + } + if err := requireRowsAffected(res, ErrResourceNotFound); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM calendar_shares WHERE owner = ? AND calendar_name = ?`, owner, name); err != nil { + return fmt.Errorf("deleting calendar's shares: %w", err) + } + return tx.Commit() +} + +// ListCalendars returns the names of all calendars owner has registered, +// sorted. +func (d *DB) ListCalendars(owner string) ([]string, error) { + return listNames(d, `SELECT name FROM calendars WHERE owner = ? ORDER BY name`, owner) +} + +// -------- address books -------- + +// CreateAddressBook registers a new address book owned by owner. Returns +// ErrResourceExists if it already exists. +func (d *DB) CreateAddressBook(owner, name string) error { + _, err := d.conn.Exec(`INSERT INTO addressbooks (owner, name) VALUES (?, ?)`, owner, name) + if err != nil { + if isUniqueConstraintErr(err) { + return ErrResourceExists + } + return fmt.Errorf("creating address book: %w", err) + } + return nil +} + +// DeleteAddressBook removes an address book registration (not the +// underlying files/objects — callers are responsible for also removing +// those via store.Store). Returns ErrResourceNotFound if it doesn't +// exist. Any sharing grants for it are removed as well. +func (d *DB) DeleteAddressBook(owner, name string) error { + tx, err := d.conn.Begin() + if err != nil { + return fmt.Errorf("deleting address book: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec(`DELETE FROM addressbooks WHERE owner = ? AND name = ?`, owner, name) + if err != nil { + return fmt.Errorf("deleting address book: %w", err) + } + if err := requireRowsAffected(res, ErrResourceNotFound); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM addressbook_shares WHERE owner = ? AND addressbook_name = ?`, owner, name); err != nil { + return fmt.Errorf("deleting address book's shares: %w", err) + } + return tx.Commit() +} + +// ListAddressBooks returns the names of all address books owner has +// registered, sorted. +func (d *DB) ListAddressBooks(owner string) ([]string, error) { + return listNames(d, `SELECT name FROM addressbooks WHERE owner = ? ORDER BY name`, owner) +} + +// -------- helpers -------- + +func listNames(d *DB, query, arg string) ([]string, error) { + rows, err := d.conn.Query(query, arg) + if err != nil { + return nil, fmt.Errorf("listing: %w", err) + } + defer rows.Close() + + var names []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + return nil, fmt.Errorf("scanning: %w", err) + } + names = append(names, n) + } + return names, rows.Err() +} + +func requireRowsAffected(res sql.Result, errIfZero error) error { + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return errIfZero + } + return nil +} + +// isUniqueConstraintErr reports whether err looks like a SQLite UNIQUE / +// PRIMARY KEY constraint violation. modernc.org/sqlite doesn't expose a +// typed error for this, so this matches on the driver's error message. +func isUniqueConstraintErr(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || strings.Contains(msg, "constraint failed: UNIQUE") +} diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go index 368e9e3..0d66511 100644 --- a/internal/web/dashboard.go +++ b/internal/web/dashboard.go @@ -2,6 +2,7 @@ package web import ( "context" + "fmt" "net/http" "github.com/yourusername/caldav-server/internal/db" @@ -10,83 +11,93 @@ import ( func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { username := userFromContext(r.Context()) - user, ok := s.cfg.Users[username] - if !ok { - http.Error(w, "user not found in configuration", http.StatusInternalServerError) + + resources, err := s.resourceCards(username) + if err != nil { + s.logger.Error("listing resources", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) return } - var resources []templates.ResourceCard - - for _, calName := range user.Calendars { - card := templates.ResourceCard{Kind: "calendar", Name: calName} - if s.dbase != nil { - shares, err := s.dbase.SharesOfCalendar(username, calName) - if err != nil { - s.logger.Warn("listing calendar shares", "error", err) - } - for _, sh := range shares { - card.Shares = append(card.Shares, templates.ShareRow{ - ResourceName: calName, - SharedWith: sh.SharedWith, - Permission: string(sh.Permission), - }) - } - } - resources = append(resources, card) - } - - for _, bookName := range user.AddressBooks { - card := templates.ResourceCard{Kind: "addressbook", Name: bookName} - if s.dbase != nil { - shares, err := s.dbase.SharesOfAddressBook(username, bookName) - if err != nil { - s.logger.Warn("listing address book shares", "error", err) - } - for _, sh := range shares { - card.Shares = append(card.Shares, templates.ShareRow{ - ResourceName: bookName, - SharedWith: sh.SharedWith, - Permission: string(sh.Permission), - }) - } - } - resources = append(resources, card) - } - var sharedWithMe []templates.SharedWithMeItem - if s.dbase != nil { - calShares, err := s.dbase.CalendarsSharedWith(username) - if err != nil { - s.logger.Warn("listing calendars shared with user", "error", err) - } - for _, sh := range calShares { - sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{ - Kind: "calendar", Owner: sh.Owner, Name: sh.CalendarName, Permission: string(sh.Permission), - }) - } - bookShares, err := s.dbase.AddressBooksSharedWith(username) - if err != nil { - s.logger.Warn("listing address books shared with user", "error", err) - } - for _, sh := range bookShares { - sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{ - Kind: "addressbook", Owner: sh.Owner, Name: sh.AddressBookName, Permission: string(sh.Permission), - }) - } + calShares, err := s.dbase.CalendarsSharedWith(username) + if err != nil { + s.logger.Warn("listing calendars shared with user", "error", err) + } + for _, sh := range calShares { + sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{ + Kind: "calendar", Owner: sh.Owner, Name: sh.CalendarName, Permission: string(sh.Permission), + }) + } + bookShares, err := s.dbase.AddressBooksSharedWith(username) + if err != nil { + s.logger.Warn("listing address books shared with user", "error", err) + } + for _, sh := range bookShares { + sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{ + Kind: "addressbook", Owner: sh.Owner, Name: sh.AddressBookName, Permission: string(sh.Permission), + }) } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.Dashboard(username, resources, sharedWithMe).Render(context.Background(), w) } +// resourceCards builds the full list of ResourceCards (calendars, then +// address books) owned by username, each with its current shares — used +// both for the initial dashboard render and to re-render the whole +// #resources list after a create/delete (since the set of cards changes, +// unlike a share update which only changes one card's contents). +func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error) { + var resources []templates.ResourceCard + + calNames, err := s.dbase.ListCalendars(username) + if err != nil { + return nil, fmt.Errorf("listing calendars: %w", err) + } + for _, calName := range calNames { + card := templates.ResourceCard{Kind: "calendar", Name: calName} + shares, err := s.dbase.SharesOfCalendar(username, calName) + if err != nil { + s.logger.Warn("listing calendar shares", "error", err) + } + for _, sh := range shares { + card.Shares = append(card.Shares, templates.ShareRow{ + ResourceName: calName, + SharedWith: sh.SharedWith, + Permission: string(sh.Permission), + }) + } + resources = append(resources, card) + } + + bookNames, err := s.dbase.ListAddressBooks(username) + if err != nil { + return nil, fmt.Errorf("listing address books: %w", err) + } + for _, bookName := range bookNames { + card := templates.ResourceCard{Kind: "addressbook", Name: bookName} + shares, err := s.dbase.SharesOfAddressBook(username, bookName) + if err != nil { + s.logger.Warn("listing address book shares", "error", err) + } + for _, sh := range shares { + card.Shares = append(card.Shares, templates.ShareRow{ + ResourceName: bookName, + SharedWith: sh.SharedWith, + Permission: string(sh.Permission), + }) + } + resources = append(resources, card) + } + + return resources, nil +} + // resourceCardFor rebuilds a single ResourceCard (used to re-render just // the card an htmx request just changed, for partial updates). func (s *Server) resourceCardFor(username, kind, name string) (templates.ResourceCard, error) { card := templates.ResourceCard{Kind: kind, Name: name} - if s.dbase == nil { - return card, nil - } var shares []templates.ShareRow if kind == "calendar" { diff --git a/internal/web/resources.go b/internal/web/resources.go new file mode 100644 index 0000000..2b09b0c --- /dev/null +++ b/internal/web/resources.go @@ -0,0 +1,102 @@ +package web + +import ( + "context" + "net/http" + "regexp" + "strings" + + "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/web/templates" +) + +// resourceNameRe restricts calendar/address book names to characters that +// are safe as both a URL path segment and a filesystem directory name. +var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +// handleCalendarResource handles POST (create) and DELETE (remove) for +// the current user's own calendars, mounted at /resources/calendar. +func (s *Server) handleCalendarResource(w http.ResponseWriter, r *http.Request) { + s.handleResource(w, r, "calendar") +} + +func (s *Server) handleAddressBookResource(w http.ResponseWriter, r *http.Request) { + s.handleResource(w, r, "addressbook") +} + +func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) { + username := userFromContext(r.Context()) + + // htmx v2 sends DELETE request parameters as URL query parameters, not + // a request body — unlike POST/PUT/PATCH (see internal/web/shares.go). + if r.Method == http.MethodDelete { + r.PostForm = r.URL.Query() + } else if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.PostForm.Get("name")) + if !resourceNameRe.MatchString(name) { + http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) + return + } + + collPrefix := "cal-" + if kind == "addressbook" { + collPrefix = "card-" + } + + switch r.Method { + case http.MethodPost: + var err error + if kind == "calendar" { + err = s.dbase.CreateCalendar(username, name) + } else { + err = s.dbase.CreateAddressBook(username, name) + } + if err != nil { + if err == db.ErrResourceExists { + http.Error(w, "already exists", http.StatusConflict) + return + } + s.logger.Error("creating resource", "kind", kind, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := s.store.EnsureCollection(username, collPrefix+name); err != nil { + s.logger.Error("creating resource storage", "kind", kind, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + case http.MethodDelete: + var err error + if kind == "calendar" { + err = s.dbase.DeleteCalendar(username, name) + } else { + err = s.dbase.DeleteAddressBook(username, name) + } + if err != nil && err != db.ErrResourceNotFound { + s.logger.Error("deleting resource", "kind", kind, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := s.store.DeleteCollection(username, collPrefix+name); err != nil { + s.logger.Warn("deleting resource storage", "kind", kind, "error", err) + } + default: + w.Header().Set("Allow", "POST, DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // The set of cards changed (one added/removed), so re-render the + // whole #resources list rather than a single card. + resources, err := s.resourceCards(username) + if err != nil { + s.logger.Error("listing resources", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.ResourceList(resources).Render(context.Background(), w) +} diff --git a/internal/web/resources_test.go b/internal/web/resources_test.go new file mode 100644 index 0000000..59f3f42 --- /dev/null +++ b/internal/web/resources_test.go @@ -0,0 +1,103 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestCreateAndDeleteCalendarViaWebUI(t *testing.T) { + s := newTestServer(t) + handler := s.Handler(emptyStaticFS{}) + cookie := loginAs(t, handler, "alice", "password") + + // Create a new calendar. + form := url.Values{"name": {"vacation"}} + req := httptest.NewRequest(http.MethodPost, "/resources/calendar", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("create: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "vacation") { + t.Fatalf("expected resource list to include new calendar, got: %s", rr.Body.String()) + } + + names, err := s.dbase.ListCalendars("alice") + if err != nil { + t.Fatalf("ListCalendars: %v", err) + } + found := false + for _, n := range names { + if n == "vacation" { + found = true + } + } + if !found { + t.Fatalf("expected vacation calendar to be registered, got %v", names) + } + + // Duplicate creation should fail with 409. + req = httptest.NewRequest(http.MethodPost, "/resources/calendar", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409 on duplicate create, got %d", rr.Code) + } + + // Delete it — htmx v2 sends DELETE params as a URL query string. + req = httptest.NewRequest(http.MethodDelete, "/resources/calendar?name=vacation", nil) + req.AddCookie(cookie) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("delete: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "vacation") { + t.Fatalf("expected resource list to no longer include deleted calendar, got: %s", rr.Body.String()) + } + + names, err = s.dbase.ListCalendars("alice") + if err != nil { + t.Fatalf("ListCalendars: %v", err) + } + for _, n := range names { + if n == "vacation" { + t.Fatalf("expected vacation calendar to be gone, got %v", names) + } + } +} + +func TestCreateAddressBookInvalidName(t *testing.T) { + s := newTestServer(t) + handler := s.Handler(emptyStaticFS{}) + cookie := loginAs(t, handler, "alice", "password") + + form := url.Values{"name": {"has a space"}} + req := httptest.NewRequest(http.MethodPost, "/resources/addressbook", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid name, got %d", rr.Code) + } +} + +func TestDeleteCalendarRequiresLogin(t *testing.T) { + s := newTestServer(t) + handler := s.Handler(emptyStaticFS{}) + + req := httptest.NewRequest(http.MethodDelete, "/resources/calendar?name=work", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusSeeOther { + t.Fatalf("expected redirect to login, got %d", rr.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 35f0aa8..222d683 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -13,7 +13,6 @@ import ( "github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/store" - "golang.org/x/crypto/bcrypt" ) // Server holds the dependencies needed by the web UI handlers. @@ -43,19 +42,16 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler { mux.HandleFunc("/", s.requireLogin(s.handleDashboard)) mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare)) mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare)) + mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource)) + mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource)) return mux } -// authenticate validates username/password against the configured users, -// mirroring internal/auth's Basic Auth check. +// authenticate validates username/password against the database, mirroring +// internal/auth's Basic Auth check. func (s *Server) authenticate(username, password string) bool { - user, ok := s.cfg.Users[username] - if !ok { - _ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalidinvalidinvalidinvalidinvalidinval"), []byte(password)) - return false - } - return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil + return s.dbase.VerifyPassword(username, password) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 4661d69..716657b 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -14,7 +14,6 @@ import ( "github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/store" - "golang.org/x/crypto/bcrypt" ) func newTestServer(t *testing.T) *Server { @@ -31,16 +30,21 @@ func newTestServer(t *testing.T) *Server { } t.Cleanup(func() { dbase.Close() }) - hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost) - if err != nil { - t.Fatalf("GenerateFromPassword: %v", err) + cfg := &config.Config{} + if err := dbase.CreateUser("alice", "password", "", ""); err != nil { + t.Fatalf("CreateUser alice: %v", err) } - - cfg := &config.Config{ - Users: map[string]config.UserConfig{ - "alice": {Password: string(hash), Calendars: []string{"work"}, AddressBooks: []string{"contacts"}}, - "bob": {Password: string(hash), Calendars: []string{"personal"}}, - }, + if err := dbase.CreateUser("bob", "password", "", ""); err != nil { + t.Fatalf("CreateUser bob: %v", err) + } + if err := dbase.CreateCalendar("alice", "work"); err != nil { + t.Fatalf("CreateCalendar alice/work: %v", err) + } + if err := dbase.CreateAddressBook("alice", "contacts"); err != nil { + t.Fatalf("CreateAddressBook alice/contacts: %v", err) + } + if err := dbase.CreateCalendar("bob", "personal"); err != nil { + t.Fatalf("CreateCalendar bob/personal: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return NewServer(cfg, st, dbase, logger) diff --git a/internal/web/shares.go b/internal/web/shares.go index 5e570f5..1d5e3f6 100644 --- a/internal/web/shares.go +++ b/internal/web/shares.go @@ -24,11 +24,6 @@ func (s *Server) handleAddressBookShare(w http.ResponseWriter, r *http.Request) func (s *Server) handleShare(w http.ResponseWriter, r *http.Request, kind string) { username := userFromContext(r.Context()) - if s.dbase == nil { - http.Error(w, "sharing is not available (no database configured)", http.StatusServiceUnavailable) - return - } - // htmx v2 sends DELETE request parameters (including hx-vals) as URL // query parameters, not a request body — unlike POST/PUT/PATCH. if r.Method == http.MethodDelete { @@ -100,15 +95,18 @@ func (s *Server) handleShare(w http.ResponseWriter, r *http.Request, kind string // actually configured for username, to prevent sharing arbitrary/other // users' resources via a forged form post. func (s *Server) ownsResource(username, kind, resource string) bool { - user, ok := s.cfg.Users[username] - if !ok { - return false - } - var list []string + var ( + list []string + err error + ) if kind == "calendar" { - list = user.Calendars + list, err = s.dbase.ListCalendars(username) } else { - list = user.AddressBooks + list, err = s.dbase.ListAddressBooks(username) + } + if err != nil { + s.logger.Warn("checking resource ownership", "kind", kind, "error", err) + return false } for _, n := range list { if n == resource { diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ index 7ffb9b5..8a742d7 100644 --- a/internal/web/templates/dashboard.templ +++ b/internal/web/templates/dashboard.templ @@ -28,12 +28,48 @@ type SharedWithMeItem struct { templ Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedWithMeItem) { @Layout("Dashboard", username) {

Your calendars & address books

-
- for _, r := range resources { - @ResourceCardView(r) - } + +
+
+
+ + +
+ +
+
+
+ + +
+ +
+ @ResourceList(resources) + if len(sharedWithMe) > 0 {

Shared with you

    @@ -51,6 +87,22 @@ templ Dashboard(username string, resources []ResourceCard, sharedWithMe []Shared } } +// ResourceList renders the #resources container. It's re-rendered as a +// whole after a create/delete (which changes the set of cards), whereas a +// share update only swaps a single ResourceCardView. +templ ResourceList(resources []ResourceCard) { +
    + for _, r := range resources { + @ResourceCardView(r) + } + if len(resources) == 0 { +

    + You don't have any calendars or address books yet — add one above. +

    + } +
    +} + templ ResourceCardView(r ResourceCard) {
    @@ -58,6 +110,16 @@ templ ResourceCardView(r ResourceCard) { { r.Name } { r.Kind } +
      @@ -121,3 +183,15 @@ func shareEndpoint(kind string) string { func shareVals(resource, sharedWith string) string { return `{"resource": "` + resource + `", "shared_with": "` + sharedWith + `"}` } + +func resourceEndpoint(kind string) string { + if kind == "calendar" { + return "/web/resources/calendar" + } + return "/web/resources/addressbook" +} + +func resourceVals(name string) string { + return `{"name": "` + name + `"}` +} + diff --git a/internal/web/templates/dashboard_templ.go b/internal/web/templates/dashboard_templ.go index 247d802..3530d50 100644 --- a/internal/web/templates/dashboard_templ.go +++ b/internal/web/templates/dashboard_templ.go @@ -66,17 +66,15 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

      Your calendars & address books

      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

      Your calendars & address books

      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, r := range resources { - templ_7745c5c3_Err = ResourceCardView(r).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + templ_7745c5c3_Err = ResourceList(resources).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -93,7 +91,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 43, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 79, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -106,7 +104,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 43, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 79, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -119,7 +117,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 44, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 80, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -132,7 +130,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 46, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 82, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -158,7 +156,10 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW }) } -func ResourceCardView(r ResourceCard) templ.Component { +// ResourceList renders the #resources container. It's re-rendered as a +// whole after a create/delete (which changes the set of cards), whereas a +// share update only swaps a single ResourceCardView. +func ResourceList(resources []ResourceCard) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -179,179 +180,263 @@ func ResourceCardView(r ResourceCard) templ.Component { templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 55, Col: 46} + for _, r := range resources { + templ_7745c5c3_Err = ResourceCardView(r).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) + if len(resources) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

      You don't have any calendars or address books yet — add one above.

      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\">

      ") + return nil + }) +} + +func ResourceCardView(r ResourceCard) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\">

      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind) + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 59, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 110, Col: 12} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

        ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 111, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

        ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, sh := range r.Shares { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
      • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 66, Col: 26} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 68, Col: 81} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(r.Shares) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
      • Not shared with anyone yet.
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
      • Not shared with anyone yet.
      • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -370,4 +455,15 @@ func shareVals(resource, sharedWith string) string { return `{"resource": "` + resource + `", "shared_with": "` + sharedWith + `"}` } +func resourceEndpoint(kind string) string { + if kind == "calendar" { + return "/web/resources/calendar" + } + return "/web/resources/addressbook" +} + +func resourceVals(name string) string { + return `{"name": "` + name + `"}` +} + var _ = templruntime.GeneratedTemplate diff --git a/tools/nidusctl/main.go b/tools/nidusctl/main.go index a431664..968d886 100644 --- a/tools/nidusctl/main.go +++ b/tools/nidusctl/main.go @@ -1,10 +1,10 @@ // 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. +// server. It manages user accounts, calendars, address books, and +// calendar/address-book sharing grants, all stored in the SQLite +// database at /nidus.db; it doesn't talk to a running server, +// so the server should be restarted after adding/removing users or +// resources so it picks up the change (every request re-reads from the +// database, so no cache invalidation is needed beyond that). package main import ( @@ -15,6 +15,7 @@ import ( "github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/store" ) func main() { @@ -47,11 +48,19 @@ func run(args []string) int { } defer dbase.Close() + st, err := store.NewStore(cfg.Storage.DataDir) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening store %q: %v\n", cfg.Storage.DataDir, err) + return 1 + } + switch rest[0] { + case "user": + return runUser(dbase, rest[1:]) case "calendar", "cal": - return runCalendar(cfg, dbase, rest[1:]) + return runCalendar(dbase, st, rest[1:]) case "addressbook", "card": - return runAddressBook(cfg, dbase, rest[1:]) + return runAddressBook(dbase, st, rest[1:]) case "help", "-h", "--help": usage() return 0 @@ -63,39 +72,98 @@ func run(args []string) int { } func usage() { - fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server sharing grants + fmt.Fprint(os.Stderr, `nidusctl - manage nidus DAV server users, resources, and sharing grants Usage: + nidusctl [-config config.yaml] user create [--display-name NAME] [--email EMAIL] [--password PW] + nidusctl [-config config.yaml] user delete + nidusctl [-config config.yaml] user list + nidusctl [-config config.yaml] user passwd [--password PW] + + nidusctl [-config config.yaml] calendar create + nidusctl [-config config.yaml] calendar delete + nidusctl [-config config.yaml] calendar list 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] calendar unshare + nidusctl [-config config.yaml] calendar shares + + nidusctl [-config config.yaml] addressbook create + nidusctl [-config config.yaml] addressbook delete + nidusctl [-config config.yaml] addressbook list + nidusctl [-config config.yaml] addressbook share nidusctl [-config config.yaml] addressbook unshare - nidusctl [-config config.yaml] addressbook shares + nidusctl [-config config.yaml] addressbook shares Examples: + nidusctl user create alice --display-name "Alice Smith" --email alice@example.com + nidusctl calendar create alice work 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) - } +// 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(cfg *config.Config, dbase *db.DB, args []string) int { +func runCalendar(dbase *db.DB, st *store.Store, args []string) int { if len(args) < 1 { usage() return 2 } switch args[0] { + case "create": + if len(args) != 3 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create ") + return 2 + } + owner, calName := args[1], args[2] + if err := dbase.CreateCalendar(owner, calName); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if err := st.EnsureCollection(owner, "cal-"+calName); err != nil { + fmt.Fprintf(os.Stderr, "warning: creating storage directory: %v\n", err) + } + fmt.Printf("created calendar %q for %s\n", calName, owner) + return 0 + + case "delete": + if len(args) != 3 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar delete ") + return 2 + } + owner, calName := args[1], args[2] + if err := dbase.DeleteCalendar(owner, calName); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if err := st.DeleteCollection(owner, "cal-"+calName); err != nil { + fmt.Fprintf(os.Stderr, "warning: removing storage directory: %v\n", err) + } + fmt.Printf("deleted calendar %q for %s\n", calName, owner) + return 0 + + case "list": + if len(args) != 2 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar list ") + return 2 + } + owner := args[1] + names, err := dbase.ListCalendars(owner) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + for _, n := range names { + fmt.Println(n) + } + return 0 + case "share": if len(args) != 5 { fmt.Fprintln(os.Stderr, "usage: nidusctl calendar share ") @@ -107,8 +175,8 @@ func runCalendar(cfg *config.Config, dbase *db.DB, args []string) int { 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) + warnIfUnknownUser(dbase, owner) + warnIfUnknownUser(dbase, user) if err := dbase.ShareCalendar(owner, calName, user, perm); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 @@ -155,12 +223,60 @@ func runCalendar(cfg *config.Config, dbase *db.DB, args []string) int { } } -func runAddressBook(cfg *config.Config, dbase *db.DB, args []string) int { +func runAddressBook(dbase *db.DB, st *store.Store, args []string) int { if len(args) < 1 { usage() return 2 } switch args[0] { + case "create": + if len(args) != 3 { + fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook create ") + return 2 + } + owner, bookName := args[1], args[2] + if err := dbase.CreateAddressBook(owner, bookName); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if err := st.EnsureCollection(owner, "card-"+bookName); err != nil { + fmt.Fprintf(os.Stderr, "warning: creating storage directory: %v\n", err) + } + fmt.Printf("created address book %q for %s\n", bookName, owner) + return 0 + + case "delete": + if len(args) != 3 { + fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook delete ") + return 2 + } + owner, bookName := args[1], args[2] + if err := dbase.DeleteAddressBook(owner, bookName); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if err := st.DeleteCollection(owner, "card-"+bookName); err != nil { + fmt.Fprintf(os.Stderr, "warning: removing storage directory: %v\n", err) + } + fmt.Printf("deleted address book %q for %s\n", bookName, owner) + return 0 + + case "list": + if len(args) != 2 { + fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook list ") + return 2 + } + owner := args[1] + names, err := dbase.ListAddressBooks(owner) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + for _, n := range names { + fmt.Println(n) + } + return 0 + case "share": if len(args) != 5 { fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook share ") @@ -172,8 +288,8 @@ func runAddressBook(cfg *config.Config, dbase *db.DB, args []string) int { 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) + warnIfUnknownUser(dbase, owner) + warnIfUnknownUser(dbase, user) if err := dbase.ShareAddressBook(owner, bookName, user, perm); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 @@ -219,3 +335,12 @@ func runAddressBook(cfg *config.Config, dbase *db.DB, args []string) int { return 2 } } + +// warnIfUnknownUser reports (without failing) if username is not a +// registered user — the share is still recorded, since a user could be +// created afterwards. +func warnIfUnknownUser(dbase *db.DB, username string) { + if _, err := dbase.GetUser(username); err != nil { + fmt.Fprintf(os.Stderr, "warning: %q is not a registered user (continuing anyway)\n", username) + } +} diff --git a/tools/nidusctl/users.go b/tools/nidusctl/users.go new file mode 100644 index 0000000..b83f40b --- /dev/null +++ b/tools/nidusctl/users.go @@ -0,0 +1,167 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/yourusername/caldav-server/internal/db" + "golang.org/x/term" +) + +func runUser(dbase *db.DB, args []string) int { + if len(args) < 1 { + usage() + return 2 + } + switch args[0] { + case "create": + return userCreate(dbase, args[1:]) + case "delete": + return userDelete(dbase, args[1:]) + case "list": + return userList(dbase, args[1:]) + case "passwd": + return userPasswd(dbase, args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown user subcommand %q\n", args[0]) + return 2 + } +} + +func userCreate(dbase *db.DB, args []string) int { + fs := newFlagSet("nidusctl user create") + displayName := fs.String("display-name", "", "display name shown in DAV clients") + email := fs.String("email", "", "email address") + password := fs.String("password", "", "password (omit to be prompted, recommended)") + if err := fs.Parse(args); err != nil { + return 2 + } + rest := fs.Args() + if len(rest) != 1 { + fmt.Fprintln(os.Stderr, "usage: nidusctl user create [--display-name NAME] [--email EMAIL] [--password PASSWORD]") + return 2 + } + username := rest[0] + + pw := *password + if pw == "" { + var err error + pw, err = promptPassword(username) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) + return 1 + } + } + + if err := dbase.CreateUser(username, pw, *displayName, *email); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("created user %q\n", username) + return 0 +} + +func userDelete(dbase *db.DB, args []string) int { + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: nidusctl user delete ") + return 2 + } + username := args[0] + if err := dbase.DeleteUser(username); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("deleted user %q (and its calendars, address books, and shares)\n", username) + return 0 +} + +func userList(dbase *db.DB, args []string) int { + if len(args) != 0 { + fmt.Fprintln(os.Stderr, "usage: nidusctl user list") + return 2 + } + users, err := dbase.ListUsers() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if len(users) == 0 { + fmt.Println("no users") + return 0 + } + for _, u := range users { + fmt.Printf("%s\t%s\t%s\n", u.Username, u.DisplayName, u.Email) + } + return 0 +} + +func userPasswd(dbase *db.DB, args []string) int { + fs := newFlagSet("nidusctl user passwd") + password := fs.String("password", "", "new password (omit to be prompted, recommended)") + if err := fs.Parse(args); err != nil { + return 2 + } + rest := fs.Args() + if len(rest) != 1 { + fmt.Fprintln(os.Stderr, "usage: nidusctl user passwd [--password PASSWORD]") + return 2 + } + username := rest[0] + + pw := *password + if pw == "" { + var err error + pw, err = promptPassword(username) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading password: %v\n", err) + return 1 + } + } + + if err := dbase.SetPassword(username, pw); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("updated password for %q\n", username) + return 0 +} + +// promptPassword reads a password twice from the terminal (without echo, +// if stdin is a TTY) and confirms both entries match. +func promptPassword(username string) (string, error) { + if term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintf(os.Stderr, "password for %s: ", username) + pw1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + fmt.Fprint(os.Stderr, "confirm password: ") + pw2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + if string(pw1) != string(pw2) { + return "", fmt.Errorf("passwords do not match") + } + if len(pw1) == 0 { + return "", fmt.Errorf("password must not be empty") + } + return string(pw1), nil + } + + // Not a TTY (e.g. piped input in scripts/tests) — read a single line. + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return "", err + } + pw := strings.TrimRight(line, "\r\n") + if pw == "" { + return "", fmt.Errorf("password must not be empty") + } + return pw, nil +} diff --git a/web/static/app.css b/web/static/app.css index 6fc1e56..e97796a 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-600:oklch(57.7% .245 27.325);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mb-1{margin-bottom:var(--spacing)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.flex{display:flex}.hidden{display:none}.h-full{height:100%}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.resize{resize:both}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-red-200{border-color:var(--color-red-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-red-600{color:var(--color-red-600)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-600:oklch(57.7% .245 27.325);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mb-1{margin-bottom:var(--spacing)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.flex{display:flex}.hidden{display:none}.h-full{height:100%}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.resize{resize:both}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-red-200{border-color:var(--color-red-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-red-600{color:var(--color-red-600)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file