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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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"`
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
+57
-46
@@ -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,52 +11,15 @@ 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)
|
||||||
@@ -74,19 +38,66 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
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" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -28,11 +28,47 @@ 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 & address books</h1>
|
<h1 class="text-2xl font-semibold mb-6">Your calendars & 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>
|
</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>
|
||||||
|
|
||||||
|
@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>
|
||||||
@@ -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 + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 & 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 & 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, " ")
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
|
|
||||||
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,86 +180,100 @@ 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 {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 55, Col: 46}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
|
||||||
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\">")
|
}
|
||||||
|
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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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
|
|
||||||
}
|
|
||||||
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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(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: 66, Col: 26}
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
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, 15, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(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: 68, Col: 81}
|
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.EscapeString(templ_7745c5c3_Var12))
|
_, 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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -266,92 +281,162 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(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: 72, Col: 49}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
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, 18, "\" hx-target=\"")
|
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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, sh := range r.Shares {
|
||||||
|
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 {
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</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_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
templ_7745c5c3_Var17, 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: 133, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
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, 23, "\" hx-vals=\"")
|
||||||
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_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, 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: 90, Col: 51}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
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, 24, "\" hx-target=\"")
|
||||||
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_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
templ_7745c5c3_Var19, 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: 93, Col: 54}
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
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, 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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.Shares) == 0 {
|
||||||
|
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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</ul><form class=\"flex items-end gap-2\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, 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: 151, Col: 34}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, 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: 152, Col: 51}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
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_Var23)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|||||||
+148
-23
@@ -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 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user