Move users, calendars, and address books from config.yaml into the database

BREAKING CHANGE: the users:/config-based collection setup is gone. All
user, calendar, and address-book data now lives in the SQLite DB
(internal/db) and is managed exclusively via nidusctl or the web UI.
Existing deployments must recreate their users after upgrading:
  nidusctl user create <username>
  nidusctl calendar create <username> <name>
  nidusctl addressbook create <username> <name>

- internal/db: new users, calendars, addressbooks tables with FK cascade
  delete; foreign_keys pragma enabled; internal/db/users.go implements
  full CRUD + bcrypt auth (CreateUser, VerifyPassword, ListUsers,
  CreateCalendar/AddressBook, etc).
- internal/config: removed Users/UserConfig entirely.
- internal/auth: Basic Auth now checks credentials via db.DB instead of
  cfg.Users.
- internal/caldav, internal/carddav: ListCalendars/ListAddressBooks and
  Create/Delete now backed by the DB.
- internal/web: login uses db.VerifyPassword; new resources.go adds
  create/delete handlers for calendars/address books at
  /web/resources/{calendar,addressbook}; dashboard gained create forms
  and per-card delete buttons (templ + htmx, no hyperscript).
- tools/nidusctl: new user create/delete/list/passwd commands (masked
  interactive password prompt via golang.org/x/term) plus create/delete/
  list subcommands for calendar/addressbook.
- cmd/server/main.go: pre-creates on-disk collections from the DB at
  startup instead of cfg.Users; warns when no users exist yet.
- Updated tests to seed data via the DB; added resources_test.go for the
  new web UI handlers.
- README.md and .github/copilot-instructions.md updated to document the
  new nidusctl commands and the DB-backed architecture.

Verified end-to-end against a live test server: nidusctl user/calendar/
addressbook create, DAV Basic Auth PROPFIND, web login, dashboard
rendering, and web UI create/delete of resources all confirmed working.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-19 12:43:34 +02:00
co-authored by Copilot
parent 3dc49b6b22
commit 7d4f28de3c
25 changed files with 1440 additions and 365 deletions
+49 -24
View File
@@ -29,20 +29,22 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
## Architecture ## Architecture
- `cmd/server/main.go` — entrypoint. Loads config, builds the `slog.Logger`, - `cmd/server/main.go` — entrypoint. Loads config, builds the `slog.Logger`,
constructs the `store.Store`, pre-creates each user's configured constructs the `store.Store` and `db.DB`, lists all users/calendars/
calendars/address-books as collections, wires up `auth.Middleware`, and address-books from the DB (`dbase.ListUsers`/`ListCalendars`/
builds the `http.ServeMux` (`buildMux`). Routes: `/cal/`, `/card/`, `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), `/files/`, `/.well-known/{caldav,carddav}`, `/healthz` (unauthenticated),
and `/` (welcome page on GET/HEAD only; any other method — e.g. a WebDAV 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 client pointed at the wrong URL — gets `405` instead of a misleading
`200`). `200`).
- `internal/config` — YAML config loading (`config.Load`), defaults - `internal/config` — YAML config loading (`config.Load`), defaults
(`applyDefaults`), and validation (`validate`). `Config.Users` is a (`applyDefaults`), and validation (`validate`). Holds only server/auth/
`map[string]UserConfig` keyed by username; each user has a bcrypt storage/logging/TLS settings — **no user, calendar, or address-book
`Password`, `Calendars`, and `AddressBooks` lists that seed collection data**; all of that lives in `internal/db` now (see below).
names.
- `internal/auth` — HTTP Basic Auth middleware (`auth.Middleware.Wrap`). - `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. `*Principal{Username, DisplayName, Email}` in the request context.
Downstream code retrieves it with `auth.FromContext(ctx)` — every backend Downstream code retrieves it with `auth.FromContext(ctx)` — every backend
method needs this and returns `webdav.NewHTTPError(http.StatusUnauthorized, ...)` 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 (`QueryCalendarObjects`) currently list all objects and filter in-memory
via `caldav.Filter` — fine for small collections, not optimized for via `caldav.Filter` — fine for small collections, not optimized for
scale. scale.
**Sharing**: both backends take an `*db.DB` (may be `nil` to disable **Sharing**: both backends take an `*db.DB` (required — used for both
sharing). A calendar/address book shared with a user is exposed under the base `ListCalendars`/`ListAddressBooks`/`Create*`/`Delete*`
operations and sharing). A calendar/address book shared with a user is exposed under
the synthetic local name `<owner>~<name>` (see `sharedNameSep`, the synthetic local name `<owner>~<name>` (see `sharedNameSep`,
`sharedCalendarName`/`sharedBookName`) in that user's own home-set — `sharedCalendarName`/`sharedBookName`) in that user's own home-set —
`resolveCalendar`/`resolveBook` split the local name back into `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 `<data_dir>/nidus.db`. Only `modernc.org/sqlite` (pure Go, no CGO) at `<data_dir>/nidus.db`. Only
one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a
single writer; this is intentionally simple and not meant to scale to single writer; this is intentionally simple and not meant to scale to
heavy concurrent write load — fine for sharing grants (and future user heavy concurrent write load. Schema lives in `migrate()`; there's no
management), not a general-purpose data store (that's what
`internal/store` is for). Schema lives in `migrate()`; there's no
migration framework, just idempotent `CREATE TABLE IF NOT EXISTS`. 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`, - `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`,
mounted at the single fixed URL `/files/` for all users (no username in mounted at the single fixed URL `/files/` for all users (no username in
the path either). `NewHandler` caches one `*xwebdav.Handler` per 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 `LockSystem` — the handler (and its lock table) must be created once and
reused, not per-request, or LOCK/UNLOCK state resets on every call. reused, not per-request, or LOCK/UNLOCK state resets on every call.
- `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) to - `tools/hashpwd` — standalone CLI (`go run ./tools/hashpwd <password>`) 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 - `tools/nidusctl` — standalone admin CLI (`go run ./tools/nidusctl
-config config.yaml <calendar|addressbook> <share|unshare|shares> ...`) -config config.yaml <user|calendar|addressbook> ...`) — the only way to
for managing `internal/db` sharing grants. It's a thin argv-parsing create/delete users, calendars, and address books, plus manage sharing
wrapper around `db.DB`'s methods — no server interaction, no daemon, no grants (`calendar|addressbook share|unshare|shares`). It's a thin
RPC; it just opens the same SQLite file the running server uses. Since argv-parsing wrapper around `db.DB`'s methods (`tools/nidusctl/main.go`
the caldav/carddav backends query the shares tables on every request routes subcommands, `tools/nidusctl/users.go` implements `user
(no caching), changes take effect immediately without restarting the create/delete/list/passwd` with interactive masked password prompting
server. 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` - `internal/web` — the web UI, mounted at `/web/` in `cmd/server/main.go`
(`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st, (`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st,
dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own
routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the
outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s
Basic Auth: logins go through Basic Auth: logins go through
`/web/login` (username/password checked against `cfg.Users` the same way `/web/login` (username/password checked against the DB the same way
Basic Auth does, via bcrypt) and issue an opaque random session token Basic Auth does, via `dbase.VerifyPassword`) and issue an opaque random session token
stored in the `web_sessions` SQLite table (`db.CreateSession`/ stored in the `web_sessions` SQLite table (`db.CreateSession`/
`SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an `SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an
`HttpOnly` cookie (`sessionCookieName` in `internal/web/session.go`). `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 `internal/web/dashboard.go` renders the logged-in user's own
calendars/address books plus who they're shared with calendars/address books plus who they're shared with
(`SharesOfCalendar`/`SharesOfAddressBook`) and what's shared with them (`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 handles POST (create/update share) and DELETE (revoke) at
`/web/shares/{calendar,addressbook}`, re-rendering just the affected `/web/shares/{calendar,addressbook}`, re-rendering just the affected
resource card for htmx's `hx-swap="outerHTML"`; it always checks resource card for htmx's `hx-swap="outerHTML"`; it always checks
+61 -34
View File
@@ -30,38 +30,20 @@ A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go.
go mod tidy go mod tidy
``` ```
### 2. Generate password hashes ### 2. Create your `config.yaml`
```bash
go run ./tools/hashpwd mysecretpassword
# Outputs: $2b$12$...
```
### 3. Create your `config.yaml`
Copy the example config and edit it — `config.yaml` is git-ignored so your 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 ```bash
cp config.example.yaml config.yaml 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 ### 3. Run the server
users:
alice:
password: "$2b$12$<hash generated above>"
display_name: "Alice Smith"
email: "alice@example.com"
calendars:
- personal
- work
address_books:
- contacts
```
### 4. Run the server
```bash ```bash
make run make run
@@ -71,6 +53,20 @@ go run ./cmd/server -config config.yaml
The server starts at **http://localhost:8080**. 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 ## Docker
@@ -256,18 +252,49 @@ tls:
enabled: false enabled: false
cert_file: "" cert_file: ""
key_file: "" key_file: ""
users:
<username>:
password: "<bcrypt hash>"
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 <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl user delete <username>
nidusctl user list
nidusctl user passwd <username> [--password PW]
# Calendars
nidusctl calendar create <owner> <calendar>
nidusctl calendar delete <owner> <calendar>
nidusctl calendar list <owner>
nidusctl calendar share <owner> <calendar> <user> <read|write>
nidusctl calendar unshare <owner> <calendar> <user>
nidusctl calendar shares <owner> <calendar>
# Address books
nidusctl addressbook create <owner> <book>
nidusctl addressbook delete <owner> <book>
nidusctl addressbook list <owner>
nidusctl addressbook share <owner> <book> <user> <read|write>
nidusctl addressbook unshare <owner> <book> <user>
nidusctl addressbook shares <owner> <book>
```
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 ## Project layout
+34 -10
View File
@@ -51,7 +51,7 @@ func main() {
os.Exit(1) 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") dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db")
dbase, err := db.Open(dbPath) dbase, err := db.Open(dbPath)
if err != nil { if err != nil {
@@ -60,22 +60,46 @@ func main() {
} }
defer dbase.Close() defer dbase.Close()
// Pre-create default collections for each user if n, err := dbase.UserCount(); err != nil {
for username, user := range cfg.Users { logger.Error("counting users", "error", err)
for _, cal := range user.Calendars { os.Exit(1)
if err := st.EnsureCollection(username, "cal-"+cal); err != nil { } else if n == 0 {
logger.Warn("creating calendar collection", "user", username, "cal", cal, "error", err) logger.Warn("no users exist yet — create one with: nidusctl user create <username>")
}
// 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 { books, err := dbase.ListAddressBooks(user.Username)
if err := st.EnsureCollection(username, "card-"+book); err != nil { if err != nil {
logger.Warn("creating address book collection", "user", username, "book", book, "error", err) 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 ---- // ---- Middleware ----
authMw := auth.NewMiddleware(cfg, logger) authMw := auth.NewMiddleware(cfg, dbase, logger)
// ---- Handlers ---- // ---- Handlers ----
calHandler := caldav.NewHandler(cfg, st, dbase, logger) calHandler := caldav.NewHandler(cfg, st, dbase, logger)
+10 -13
View File
@@ -19,16 +19,13 @@ tls:
# cert_file: "/etc/ssl/certs/dav.crt" # cert_file: "/etc/ssl/certs/dav.crt"
# key_file: "/etc/ssl/private/dav.key" # key_file: "/etc/ssl/private/dav.key"
# Users: passwords must be bcrypt hashes. # Users, calendars, and address books are no longer configured here — they
# Generate with: htpasswd -nB alice # are stored in the database (<data_dir>/nidus.db) and managed with
# or in Go: bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.DefaultCost) # nidusctl or the web UI (/web/):
users: #
alice: # nidusctl user create alice --password mysecretpassword --display-name "Alice Smith" --email alice@example.com
password: "$2a$10$9.WEs0uz5TaNJLQbSTLrX.Te.BIe8XTTykVRzZbSHQMEkyFb8Sq/O" # mysecretpassword # nidusctl calendar create alice personal
display_name: "Alice Smith" # nidusctl calendar create alice work
email: "alice@example.com" # nidusctl addressbook create alice contacts
calendars: #
- personal # Run `nidusctl help` for the full command list.
- work
address_books:
- contacts
+1
View File
@@ -21,6 +21,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/teambition/rrule-go v1.8.2 // indirect github.com/teambition/rrule-go v1.8.2 // indirect
golang.org/x/sys v0.47.0 // 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/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+2
View File
@@ -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/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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+10 -13
View File
@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/config"
"golang.org/x/crypto/bcrypt" "github.com/yourusername/caldav-server/internal/db"
) )
type contextKey string type contextKey string
@@ -16,11 +16,12 @@ const userContextKey contextKey = "authenticated_user"
// Middleware wraps an http.Handler with HTTP Basic Auth enforcement. // Middleware wraps an http.Handler with HTTP Basic Auth enforcement.
type Middleware struct { type Middleware struct {
cfg *config.Config cfg *config.Config
dbase *db.DB
logger *slog.Logger logger *slog.Logger
} }
func NewMiddleware(cfg *config.Config, logger *slog.Logger) *Middleware { func NewMiddleware(cfg *config.Config, dbase *db.DB, logger *slog.Logger) *Middleware {
return &Middleware{cfg: cfg, logger: logger} return &Middleware{cfg: cfg, dbase: dbase, logger: logger}
} }
// Wrap returns an http.Handler that requires valid Basic Auth credentials // 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")) _, _ = w.Write([]byte("Unauthorized"))
} }
// authenticate validates username/password against config. // authenticate validates username/password against the database.
func (m *Middleware) authenticate(username, password string) (*config.UserConfig, error) { func (m *Middleware) authenticate(username, password string) (*db.User, error) {
user, ok := m.cfg.Users[username] user, err := m.dbase.GetUser(username)
if !ok { if err != nil {
// constant-time comparison to avoid timing attacks
_ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalid"), []byte(password))
return nil, errUnauthorized return nil, errUnauthorized
} }
if !m.dbase.VerifyPassword(username, password) {
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, errUnauthorized return nil, errUnauthorized
} }
return user, nil
return &user, nil
} }
// Principal holds the authenticated user's identity. // Principal holds the authenticated user's identity.
+12 -6
View File
@@ -73,13 +73,13 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
} }
user, ok := b.cfg.Users[p.Username] names, err := b.dbase.ListCalendars(p.Username)
if !ok { if err != nil {
return nil, fmt.Errorf("user not found") return nil, fmt.Errorf("listing calendars: %w", err)
} }
var cals []caldav.Calendar var cals []caldav.Calendar
for _, name := range user.Calendars { for _, name := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil { if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err) b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err)
continue continue
@@ -87,10 +87,10 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
cals = append(cals, b.calendarMeta(p.Username, name, name)) 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) disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool) configured := make(map[string]bool)
for _, n := range user.Calendars { for _, n := range names {
configured["cal-"+n] = true configured["cal-"+n] = true
} }
for _, dir := range disk { 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 // existing calendar is done via ShareCalendar, not by creating one
// directly in someone else's name. // directly in someone else's name.
name := path.Base(strings.TrimSuffix(calendar.Path, "/")) 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) 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 { if err != nil {
return err 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) return b.store.DeleteCollection(owner, "cal-"+realName)
} }
+12 -5
View File
@@ -30,11 +30,18 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) {
} }
t.Cleanup(func() { dbase.Close() }) t.Cleanup(func() { dbase.Close() })
cfg := &config.Config{ cfg := &config.Config{}
Users: map[string]config.UserConfig{ if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
"alice": {Calendars: []string{"work"}}, t.Fatalf("CreateUser alice: %v", err)
"bob": {Calendars: []string{"personal"}}, }
}, 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)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewBackend(cfg, st, dbase, logger), dbase return NewBackend(cfg, st, dbase, logger), dbase
+12 -6
View File
@@ -67,13 +67,13 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook,
return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
} }
user, ok := b.cfg.Users[p.Username] names, err := b.dbase.ListAddressBooks(p.Username)
if !ok { if err != nil {
return nil, fmt.Errorf("user not found") return nil, fmt.Errorf("listing address books: %w", err)
} }
var books []carddav.AddressBook var books []carddav.AddressBook
for _, name := range user.AddressBooks { for _, name := range names {
if err := b.store.EnsureCollection(p.Username, "card-"+name); err != nil { if err := b.store.EnsureCollection(p.Username, "card-"+name); err != nil {
b.logger.Warn("ensuring address book directory", "book", name, "error", err) b.logger.Warn("ensuring address book directory", "book", name, "error", err)
continue continue
@@ -81,10 +81,10 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook,
books = append(books, b.bookMeta(p.Username, name, name)) 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) disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool) configured := make(map[string]bool)
for _, n := range user.AddressBooks { for _, n := range names {
configured["card-"+n] = true configured["card-"+n] = true
} }
for _, dir := range disk { 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")) return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
} }
name := path.Base(strings.TrimSuffix(book.Path, "/")) 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) 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 { if err != nil {
return err 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) return b.store.DeleteCollection(owner, "card-"+realName)
} }
+12 -5
View File
@@ -29,11 +29,18 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) {
} }
t.Cleanup(func() { dbase.Close() }) t.Cleanup(func() { dbase.Close() })
cfg := &config.Config{ cfg := &config.Config{}
Users: map[string]config.UserConfig{ if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
"alice": {AddressBooks: []string{"contacts"}}, t.Fatalf("CreateUser alice: %v", err)
"bob": {AddressBooks: []string{"personal"}}, }
}, 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)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewBackend(cfg, st, dbase, logger), dbase return NewBackend(cfg, st, dbase, logger), dbase
+5 -18
View File
@@ -9,12 +9,11 @@ import (
// Config is the top-level server configuration. // Config is the top-level server configuration.
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
Storage StorageConfig `yaml:"storage"` Storage StorageConfig `yaml:"storage"`
Users map[string]UserConfig `yaml:"users"` TLS TLSConfig `yaml:"tls"`
TLS TLSConfig `yaml:"tls"` Logging LoggingConfig `yaml:"logging"`
Logging LoggingConfig `yaml:"logging"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -34,15 +33,6 @@ type StorageConfig struct {
DataDir string `yaml:"data_dir"` DataDir string `yaml:"data_dir"`
} }
type UserConfig struct {
// bcrypt-hashed password (use `htpasswd -nB <user>`)
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 { type TLSConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
CertFile string `yaml:"cert_file"` 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") 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 return nil
} }
+23 -1
View File
@@ -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 { if err != nil {
return nil, fmt.Errorf("opening database %q: %w", path, err) return nil, fmt.Errorf("opening database %q: %w", path, err)
} }
@@ -53,6 +53,28 @@ func (d *DB) Close() error {
func (d *DB) migrate() error { func (d *DB) migrate() error {
const schema = ` 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 ( CREATE TABLE IF NOT EXISTS calendar_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL, owner TEXT NOT NULL,
+291
View File
@@ -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")
}
+74 -63
View File
@@ -2,6 +2,7 @@ package web
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/db"
@@ -10,83 +11,93 @@ import (
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context()) username := userFromContext(r.Context())
user, ok := s.cfg.Users[username]
if !ok { resources, err := s.resourceCards(username)
http.Error(w, "user not found in configuration", http.StatusInternalServerError) if err != nil {
s.logger.Error("listing resources", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return 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 var sharedWithMe []templates.SharedWithMeItem
if s.dbase != nil { calShares, err := s.dbase.CalendarsSharedWith(username)
calShares, err := s.dbase.CalendarsSharedWith(username) if err != nil {
if err != nil { s.logger.Warn("listing calendars shared with user", "error", err)
s.logger.Warn("listing calendars shared with user", "error", err) }
} for _, sh := range calShares {
for _, sh := range calShares { sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{
sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{ Kind: "calendar", Owner: sh.Owner, Name: sh.CalendarName, Permission: string(sh.Permission),
Kind: "calendar", Owner: sh.Owner, Name: sh.CalendarName, Permission: string(sh.Permission), })
}) }
} bookShares, err := s.dbase.AddressBooksSharedWith(username)
bookShares, err := s.dbase.AddressBooksSharedWith(username) if err != nil {
if err != nil { s.logger.Warn("listing address books shared with user", "error", err)
s.logger.Warn("listing address books shared with user", "error", err) }
} for _, sh := range bookShares {
for _, sh := range bookShares { sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{
sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{ Kind: "addressbook", Owner: sh.Owner, Name: sh.AddressBookName, Permission: string(sh.Permission),
Kind: "addressbook", Owner: sh.Owner, Name: sh.AddressBookName, Permission: string(sh.Permission), })
})
}
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.Dashboard(username, resources, sharedWithMe).Render(context.Background(), w) _ = 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 // resourceCardFor rebuilds a single ResourceCard (used to re-render just
// the card an htmx request just changed, for partial updates). // the card an htmx request just changed, for partial updates).
func (s *Server) resourceCardFor(username, kind, name string) (templates.ResourceCard, error) { func (s *Server) resourceCardFor(username, kind, name string) (templates.ResourceCard, error) {
card := templates.ResourceCard{Kind: kind, Name: name} card := templates.ResourceCard{Kind: kind, Name: name}
if s.dbase == nil {
return card, nil
}
var shares []templates.ShareRow var shares []templates.ShareRow
if kind == "calendar" { if kind == "calendar" {
+102
View File
@@ -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)
}
+103
View File
@@ -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)
}
}
+5 -9
View File
@@ -13,7 +13,6 @@ import (
"github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store" "github.com/yourusername/caldav-server/internal/store"
"golang.org/x/crypto/bcrypt"
) )
// Server holds the dependencies needed by the web UI handlers. // 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("/", s.requireLogin(s.handleDashboard))
mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare)) mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare))
mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare)) 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 return mux
} }
// authenticate validates username/password against the configured users, // authenticate validates username/password against the database, mirroring
// mirroring internal/auth's Basic Auth check. // internal/auth's Basic Auth check.
func (s *Server) authenticate(username, password string) bool { func (s *Server) authenticate(username, password string) bool {
user, ok := s.cfg.Users[username] return s.dbase.VerifyPassword(username, password)
if !ok {
_ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalidinvalidinvalidinvalidinvalidinval"), []byte(password))
return false
}
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
} }
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+14 -10
View File
@@ -14,7 +14,6 @@ import (
"github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store" "github.com/yourusername/caldav-server/internal/store"
"golang.org/x/crypto/bcrypt"
) )
func newTestServer(t *testing.T) *Server { func newTestServer(t *testing.T) *Server {
@@ -31,16 +30,21 @@ func newTestServer(t *testing.T) *Server {
} }
t.Cleanup(func() { dbase.Close() }) t.Cleanup(func() { dbase.Close() })
hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost) cfg := &config.Config{}
if err != nil { if err := dbase.CreateUser("alice", "password", "", ""); err != nil {
t.Fatalf("GenerateFromPassword: %v", err) t.Fatalf("CreateUser alice: %v", err)
} }
if err := dbase.CreateUser("bob", "password", "", ""); err != nil {
cfg := &config.Config{ t.Fatalf("CreateUser bob: %v", err)
Users: map[string]config.UserConfig{ }
"alice": {Password: string(hash), Calendars: []string{"work"}, AddressBooks: []string{"contacts"}}, if err := dbase.CreateCalendar("alice", "work"); err != nil {
"bob": {Password: string(hash), Calendars: []string{"personal"}}, 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)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewServer(cfg, st, dbase, logger) return NewServer(cfg, st, dbase, logger)
+10 -12
View File
@@ -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) { func (s *Server) handleShare(w http.ResponseWriter, r *http.Request, kind string) {
username := userFromContext(r.Context()) 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 // htmx v2 sends DELETE request parameters (including hx-vals) as URL
// query parameters, not a request body — unlike POST/PUT/PATCH. // query parameters, not a request body — unlike POST/PUT/PATCH.
if r.Method == http.MethodDelete { 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 // actually configured for username, to prevent sharing arbitrary/other
// users' resources via a forged form post. // users' resources via a forged form post.
func (s *Server) ownsResource(username, kind, resource string) bool { func (s *Server) ownsResource(username, kind, resource string) bool {
user, ok := s.cfg.Users[username] var (
if !ok { list []string
return false err error
} )
var list []string
if kind == "calendar" { if kind == "calendar" {
list = user.Calendars list, err = s.dbase.ListCalendars(username)
} else { } 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 { for _, n := range list {
if n == resource { if n == resource {
+78 -4
View File
@@ -28,12 +28,48 @@ type SharedWithMeItem struct {
templ Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedWithMeItem) { templ Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedWithMeItem) {
@Layout("Dashboard", username) { @Layout("Dashboard", username) {
<h1 class="text-2xl font-semibold mb-6">Your calendars &amp; address books</h1> <h1 class="text-2xl font-semibold mb-6">Your calendars &amp; address books</h1>
<div id="resources" class="space-y-6">
for _, r := range resources { <div class="flex gap-4 mb-6">
@ResourceCardView(r) <form
} class="flex items-end gap-2 bg-white rounded-lg border border-gray-200 p-4"
hx-post="/web/resources/calendar"
hx-target="#resources"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
>
<div>
<label class="block text-xs text-gray-500 mb-1">New calendar</label>
<input name="name" type="text" required pattern="[a-zA-Z0-9_-]{1,64}"
placeholder="e.g. work"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
</div>
<button type="submit"
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Add
</button>
</form>
<form
class="flex items-end gap-2 bg-white rounded-lg border border-gray-200 p-4"
hx-post="/web/resources/addressbook"
hx-target="#resources"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
>
<div>
<label class="block text-xs text-gray-500 mb-1">New address book</label>
<input name="name" type="text" required pattern="[a-zA-Z0-9_-]{1,64}"
placeholder="e.g. contacts"
class="rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
</div>
<button type="submit"
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Add
</button>
</form>
</div> </div>
@ResourceList(resources)
if len(sharedWithMe) > 0 { if len(sharedWithMe) > 0 {
<h2 class="text-xl font-semibold mt-10 mb-4">Shared with you</h2> <h2 class="text-xl font-semibold mt-10 mb-4">Shared with you</h2>
<ul class="divide-y divide-gray-200 bg-white rounded-lg border border-gray-200"> <ul class="divide-y divide-gray-200 bg-white rounded-lg border border-gray-200">
@@ -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) {
<div id="resources" class="space-y-6">
for _, r := range resources {
@ResourceCardView(r)
}
if len(resources) == 0 {
<p class="text-sm text-gray-400">
You don't have any calendars or address books yet add one above.
</p>
}
</div>
}
templ ResourceCardView(r ResourceCard) { templ ResourceCardView(r ResourceCard) {
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5"> <div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
@@ -58,6 +110,16 @@ templ ResourceCardView(r ResourceCard) {
{ r.Name } { r.Name }
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span> <span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span>
</h2> </h2>
<button
class="text-red-600 hover:underline text-xs"
hx-delete={ resourceEndpoint(r.Kind) }
hx-vals={ resourceVals(r.Name) }
hx-target="#resources"
hx-swap="outerHTML"
hx-confirm={ "Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone." }
>
Delete
</button>
</div> </div>
<ul class="divide-y divide-gray-100 mb-4"> <ul class="divide-y divide-gray-100 mb-4">
@@ -121,3 +183,15 @@ func shareEndpoint(kind string) string {
func shareVals(resource, sharedWith string) string { func shareVals(resource, sharedWith string) string {
return `{"resource": "` + resource + `", "shared_with": "` + sharedWith + `"}` 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 + `"}`
}
+200 -104
View File
@@ -66,17 +66,15 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars &amp; address books</h1><div id=\"resources\" class=\"space-y-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars &amp; address books</h1><div class=\"flex gap-4 mb-6\"><form class=\"flex items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/calendar\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div><label class=\"block text-xs text-gray-500 mb-1\">New calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. work\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/addressbook\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div><label class=\"block text-xs text-gray-500 mb-1\">New address book</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. contacts\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, r := range resources { templ_7745c5c3_Err = ResourceList(resources).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = ResourceCardView(r).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil {
if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err
return templ_7745c5c3_Err
}
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -93,7 +91,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -106,7 +104,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -119,7 +117,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -132,7 +130,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { 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) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 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 templ_7745c5c3_Var7 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"resources\" class=\"space-y-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string for _, r := range resources {
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Err = ResourceCardView(r).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 55, Col: 46} 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, "<p class=\"text-sm text-gray-400\">You don't have any calendars or address books yet — add one above.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-medium\">") 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, "<div id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 58, Col: 12} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 107, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-medium\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span></h2></div><ul class=\"divide-y divide-gray-100 mb-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
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, "</span></h2><button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 115, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 116, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">Delete</button></div><ul class=\"divide-y divide-gray-100 mb-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, sh := range r.Shares { for _, sh := range r.Shares {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
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, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
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, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 71, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 72, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 73, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 128, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-swap=\"outerHTML\" hx-confirm=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?") templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 75, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 130, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">Remove</button></span></li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 133, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, 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: 135, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 137, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">Remove</button></span></li>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if len(r.Shares) == 0 { if len(r.Shares) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</ul><form class=\"flex items-end gap-2\" hx-post=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</ul><form class=\"flex items-end gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 89, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 151, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-target=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 90, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 152, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 93, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 155, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -370,4 +455,15 @@ func shareVals(resource, sharedWith string) string {
return `{"resource": "` + resource + `", "shared_with": "` + sharedWith + `"}` 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 var _ = templruntime.GeneratedTemplate
+152 -27
View File
@@ -1,10 +1,10 @@
// Command nidusctl is a small administrative CLI for the nidus DAV // Command nidusctl is a small administrative CLI for the nidus DAV
// server. It currently manages calendar and address-book sharing grants // server. It manages user accounts, calendars, address books, and
// stored in the SQLite database at <data_dir>/nidus.db; it doesn't talk // calendar/address-book sharing grants, all stored in the SQLite
// to a running server, so the server should be restarted (or, in the // database at <data_dir>/nidus.db; it doesn't talk to a running server,
// future, will pick up changes automatically) after granting/revoking // so the server should be restarted after adding/removing users or
// shares — the caldav/carddav backends read shares on every request, so // resources so it picks up the change (every request re-reads from the
// no cache invalidation is needed, only an already-open DB connection. // database, so no cache invalidation is needed beyond that).
package main package main
import ( import (
@@ -15,6 +15,7 @@ import (
"github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
) )
func main() { func main() {
@@ -47,11 +48,19 @@ func run(args []string) int {
} }
defer dbase.Close() 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] { switch rest[0] {
case "user":
return runUser(dbase, rest[1:])
case "calendar", "cal": case "calendar", "cal":
return runCalendar(cfg, dbase, rest[1:]) return runCalendar(dbase, st, rest[1:])
case "addressbook", "card": case "addressbook", "card":
return runAddressBook(cfg, dbase, rest[1:]) return runAddressBook(dbase, st, rest[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
usage() usage()
return 0 return 0
@@ -63,39 +72,98 @@ func run(args []string) int {
} }
func usage() { 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: Usage:
nidusctl [-config config.yaml] user create <username> [--display-name NAME] [--email EMAIL] [--password PW]
nidusctl [-config config.yaml] user delete <username>
nidusctl [-config config.yaml] user list
nidusctl [-config config.yaml] user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar>
nidusctl [-config config.yaml] calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write> nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write>
nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user> nidusctl [-config config.yaml] calendar unshare <owner> <calendar> <user>
nidusctl [-config config.yaml] calendar shares <owner> <calendar> nidusctl [-config config.yaml] calendar shares <owner> <calendar>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook create <owner> <book>
nidusctl [-config config.yaml] addressbook delete <owner> <book>
nidusctl [-config config.yaml] addressbook list <owner>
nidusctl [-config config.yaml] addressbook share <owner> <book> <user> <read|write>
nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user> nidusctl [-config config.yaml] addressbook unshare <owner> <book> <user>
nidusctl [-config config.yaml] addressbook shares <owner> <book> nidusctl [-config config.yaml] addressbook shares <owner> <book>
Examples: 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 share alice work bob write
nidusctl calendar shares alice work nidusctl calendar shares alice work
nidusctl calendar unshare alice work bob nidusctl calendar unshare alice work bob
`) `)
} }
// userExists reports whether username is a configured user, printing a // newFlagSet creates a flag.FlagSet configured for subcommand parsing
// warning (not a hard error) if not — the share is still recorded, since // (flags may appear before or after positional args, since callers parse
// config.yaml and the share database are independent sources of truth and // flags first with fs.Parse then read fs.Args() for the rest).
// a user added after the fact shouldn't require re-running share commands. func newFlagSet(name string) *flag.FlagSet {
func warnIfUnknownUser(cfg *config.Config, username string) { return flag.NewFlagSet(name, flag.ContinueOnError)
if _, ok := cfg.Users[username]; !ok {
fmt.Fprintf(os.Stderr, "warning: %q is not a user in config.yaml (continuing anyway)\n", username)
}
} }
func runCalendar(cfg *config.Config, dbase *db.DB, args []string) int { func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
if len(args) < 1 { if len(args) < 1 {
usage() usage()
return 2 return 2
} }
switch args[0] { switch args[0] {
case "create":
if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar>")
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 <owner> <calendar>")
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 <owner>")
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": case "share":
if len(args) != 5 { if len(args) != 5 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar share <owner> <calendar> <user> <read|write>") fmt.Fprintln(os.Stderr, "usage: nidusctl calendar share <owner> <calendar> <user> <read|write>")
@@ -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) fmt.Fprintf(os.Stderr, "invalid permission %q: must be %q or %q\n", permStr, db.PermRead, db.PermWrite)
return 2 return 2
} }
warnIfUnknownUser(cfg, owner) warnIfUnknownUser(dbase, owner)
warnIfUnknownUser(cfg, user) warnIfUnknownUser(dbase, user)
if err := dbase.ShareCalendar(owner, calName, user, perm); err != nil { if err := dbase.ShareCalendar(owner, calName, user, perm); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 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 { if len(args) < 1 {
usage() usage()
return 2 return 2
} }
switch args[0] { switch args[0] {
case "create":
if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook create <owner> <book>")
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 <owner> <book>")
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 <owner>")
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": case "share":
if len(args) != 5 { if len(args) != 5 {
fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook share <owner> <book> <user> <read|write>") fmt.Fprintln(os.Stderr, "usage: nidusctl addressbook share <owner> <book> <user> <read|write>")
@@ -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) fmt.Fprintf(os.Stderr, "invalid permission %q: must be %q or %q\n", permStr, db.PermRead, db.PermWrite)
return 2 return 2
} }
warnIfUnknownUser(cfg, owner) warnIfUnknownUser(dbase, owner)
warnIfUnknownUser(cfg, user) warnIfUnknownUser(dbase, user)
if err := dbase.ShareAddressBook(owner, bookName, user, perm); err != nil { if err := dbase.ShareAddressBook(owner, bookName, user, perm); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
@@ -219,3 +335,12 @@ func runAddressBook(cfg *config.Config, dbase *db.DB, args []string) int {
return 2 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)
}
}
+167
View File
@@ -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 <username> [--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 <username>")
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 <username> [--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
}
+1 -1
View File
File diff suppressed because one or more lines are too long