From daa51d62b1497274f2c7ad13166c6c35496548e6 Mon Sep 17 00:00:00 2001 From: arnef Date: Tue, 18 Aug 2026 20:10:31 +0200 Subject: [PATCH] Add calendar/address-book sharing backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce internal/db, a small SQLite-backed store (pure-Go modernc.org/sqlite, no CGO) at /nidus.db holding calendar_shares and addressbook_shares grant tables (owner, resource name, shared-with user, read/write permission). This is the first step towards user management and a web UI: a real datastore that a future admin CLI/UI can build on, instead of the static config.yaml. Wire sharing into the CalDAV/CardDAV backends: - ListCalendars/ListAddressBooks now also include resources shared with the requesting user, exposed under the synthetic local name "~" in the grantee's own home-set — no separate account, no data copying, the object still physically lives under the owner's store.Store namespace. - All read paths (Get/List/QueryCalendarObjects, address book equivalents) resolve the synthetic name back to (owner, real name) and require any share (read or write) to exist. - All write paths (Put/Delete object, DeleteCalendar/AddressBook) additionally require a write-permission share; read-only shares get a 403 Forbidden. - CreateCalendar/CreateAddressBook remain scoped to the acting user's own namespace — sharing an existing collection is done via ShareCalendar/ ShareAddressBook, not by creating one directly in someone else's name. Add internal/db/shares_test.go (grant/lookup/update/unshare/list semantics) and internal/{caldav,carddav}/backend_test.go (shared calendar/address book visibility, write permission enforcement, unauthorized access rejection). Update README (features, new "Sharing calendars and address books" section, project layout, dependencies) and copilot-instructions.md to document the new package and sharing model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 27 +++- README.md | 26 ++++ cmd/server/main.go | 15 +- go.mod | 16 ++- go.sum | 20 +++ internal/caldav/backend.go | 139 +++++++++++++++---- internal/caldav/backend_test.go | 144 ++++++++++++++++++++ internal/carddav/backend.go | 125 +++++++++++++---- internal/carddav/backend_test.go | 138 +++++++++++++++++++ internal/db/db.go | 69 ++++++++++ internal/db/shares.go | 227 +++++++++++++++++++++++++++++++ internal/db/shares_test.go | 154 +++++++++++++++++++++ 12 files changed, 1041 insertions(+), 59 deletions(-) create mode 100644 internal/caldav/backend_test.go create mode 100644 internal/carddav/backend_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/shares.go create mode 100644 internal/db/shares_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 71b866f..c6a4232 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,7 +1,9 @@ # Copilot Instructions for nidus A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a -filesystem store. HTTP Basic Auth (bcrypt) with per-user isolated collections. +filesystem store, with calendar/address book sharing grants tracked in a +small SQLite database. HTTP Basic Auth (bcrypt) with per-user isolated +collections. ## Build, test, lint @@ -14,7 +16,9 @@ make tidy # go mod tidy go test ./internal/store/ -run TestStoreRoundTrip -v # single test ``` -There is currently only one test file: `internal/store/store_test.go`. +Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, +`internal/db/shares_test.go`, `internal/caldav/backend_test.go`, +`internal/carddav/backend_test.go`. ## Architecture @@ -76,6 +80,25 @@ There is currently only one test file: `internal/store/store_test.go`. (`QueryCalendarObjects`) currently list all objects and filter in-memory via `caldav.Filter` — fine for small collections, not optimized for scale. + **Sharing**: both backends take an `*db.DB` (may be `nil` to disable + sharing). A calendar/address book shared with a user is exposed under + the synthetic local name `~` (see `sharedNameSep`, + `sharedCalendarName`/`sharedBookName`) in that user's own home-set — + `resolveCalendar`/`resolveBook` split the local name back into + owner+real name and check the grant's permission (`db.PermRead`/ + `db.PermWrite`) via `dbase.CalendarShareFor`/`AddressBookShareFor` + before allowing reads (any share) or writes (write share only). The + shared data is never copied — it's read/written directly under the + owner's own `store.Store` namespace, just addressed via the synthetic + name from the grantee's requests. +- `internal/db` — a small `database/sql` wrapper around + `modernc.org/sqlite` (pure Go, no CGO) at `/nidus.db`. Only + one open connection is used (`SetMaxOpenConns(1)`) since SQLite allows a + single writer; this is intentionally simple and not meant to scale to + heavy concurrent write load — fine for sharing grants (and future user + management), not a general-purpose data store (that's what + `internal/store` is for). Schema lives in `migrate()`; there's no + migration framework, just idempotent `CREATE TABLE IF NOT EXISTS`. - `internal/webdav` — plain-file WebDAV via `golang.org/x/net/webdav`, mounted at the single fixed URL `/files/` for all users (no username in the path either). `NewHandler` caches one `*xwebdav.Handler` per diff --git a/README.md b/README.md index 9879ec0..665f109 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go. - HTTP Basic Auth with **bcrypt** password storage - Per-user isolated collections +- **Calendar/address book sharing** — grant other users read or write + access to your calendars/address books - Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav` - Optional **TLS** (or use a reverse proxy) - Structured logging (text or JSON) @@ -136,6 +138,28 @@ Use the GNOME Online Accounts panel: --- +## Sharing calendars and address books + +A user can grant another user read or write access to one of their own +calendars or address books. Shared resources show up automatically in the +grantee's own home-set alongside their own calendars — no separate account +or extra client configuration needed. + +Sharing grants are stored in a small SQLite database at +`/nidus.db` (not in `config.yaml`). There's no CLI or web UI for +managing shares yet — the initial backend groundwork lives in +`internal/db` (see `ShareCalendar`, `UnshareCalendar`, +`ShareAddressBook`, `UnshareAddressBook`), which a future admin CLI or web +UI will call into. + +A calendar that `alice` shares with `bob` appears in bob's calendar +home-set as `/cal/home/alice~work/` (i.e. `~`) — the +data itself still physically lives under alice's own storage; nothing is +copied. The same scheme applies to address books under `/card/home/`. +Read-only shares reject any write (PUT/DELETE) with `403 Forbidden`. + +--- + ## TLS / Reverse proxy ### Self-signed certificate (development) @@ -209,6 +233,7 @@ caldav-server/ │ ├── caldav/ # CalDAV backend │ ├── carddav/ # CardDAV backend │ ├── config/ # YAML config loader +│ ├── db/ # SQLite store (calendar/address book shares) │ ├── store/ # filesystem storage layer │ └── webdav/ # WebDAV file handler ├── tools/hashpwd/ # bcrypt password hasher CLI @@ -240,3 +265,4 @@ go test ./... -race | `golang.org/x/crypto` | bcrypt | | `golang.org/x/net` | `golang.org/x/net/webdav` | | `gopkg.in/yaml.v3` | YAML config parsing | +| `modernc.org/sqlite` | Pure-Go SQLite driver (calendar/address book shares) | diff --git a/cmd/server/main.go b/cmd/server/main.go index 8776638..78b7283 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -16,6 +17,7 @@ import ( "github.com/yourusername/caldav-server/internal/caldav" "github.com/yourusername/caldav-server/internal/carddav" "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/store" filewebdav "github.com/yourusername/caldav-server/internal/webdav" ) @@ -47,6 +49,15 @@ func main() { os.Exit(1) } + // ---- Database (calendar/address-book sharing, future user mgmt) ---- + dbPath := filepath.Join(cfg.Storage.DataDir, "nidus.db") + dbase, err := db.Open(dbPath) + if err != nil { + logger.Error("initialising database", "error", err) + os.Exit(1) + } + defer dbase.Close() + // Pre-create default collections for each user for username, user := range cfg.Users { for _, cal := range user.Calendars { @@ -65,8 +76,8 @@ func main() { authMw := auth.NewMiddleware(cfg, logger) // ---- Handlers ---- - calHandler := caldav.NewHandler(cfg, st, logger) - cardHandler := carddav.NewHandler(cfg, st, logger) + calHandler := caldav.NewHandler(cfg, st, dbase, logger) + cardHandler := carddav.NewHandler(cfg, st, dbase, logger) fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger) mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, logger) diff --git a/go.mod b/go.mod index 2ec7722..bfc03f4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/yourusername/caldav-server -go 1.22 +go 1.25.0 require ( github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 @@ -11,4 +11,16 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/teambition/rrule-go v1.8.2 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/teambition/rrule-go v1.8.2 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.56.0 // indirect +) diff --git a/go.sum b/go.sum index 5c2e882..f72835b 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,36 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-webdav v0.6.0 h1:rbnBUEXvUM2Zk65Him13LwJOBY0ISltgqM5k6T5Lq4w= github.com/emersion/go-webdav v0.6.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index 320b935..dc5faf3 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -14,24 +14,34 @@ import ( "github.com/emersion/go-webdav/caldav" "github.com/yourusername/caldav-server/internal/auth" "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/store" ) +// sharedNameSep separates the owner from the calendar name in the +// synthetic name used for calendars shared with another user, e.g. +// "alice~work" for alice's "work" calendar as seen by whoever it was +// shared with. It must not collide with characters allowed in real +// calendar names (validated wherever calendar names are taken as input). +const sharedNameSep = "~" + // Backend implements caldav.Backend using a filesystem store. type Backend struct { cfg *config.Config store *store.Store + dbase *db.DB // may be nil if sharing is not configured logger *slog.Logger } -// NewBackend creates a CalDAV backend. -func NewBackend(cfg *config.Config, st *store.Store, logger *slog.Logger) *Backend { - return &Backend{cfg: cfg, store: st, logger: logger} +// NewBackend creates a CalDAV backend. dbase may be nil, in which case +// calendar sharing is disabled (only a user's own calendars are visible). +func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend { + return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger} } // NewHandler returns an http.Handler for the /cal/ prefix. -func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.Handler { - b := NewBackend(cfg, st, logger) +func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler { + b := NewBackend(cfg, st, dbase, logger) return &caldav.Handler{Backend: b} } @@ -74,7 +84,7 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err) continue } - cals = append(cals, b.calendarMeta(p.Username, name)) + cals = append(cals, b.calendarMeta(p.Username, name, name)) } // Also include any extra calendars that exist on disk but aren't in config @@ -86,7 +96,22 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) for _, dir := range disk { if strings.HasPrefix(dir, "cal-") && !configured[dir] { name := strings.TrimPrefix(dir, "cal-") - cals = append(cals, b.calendarMeta(p.Username, name)) + cals = append(cals, b.calendarMeta(p.Username, name, name)) + } + } + + // Include calendars other users have shared with this one. + if b.dbase != nil { + shares, err := b.dbase.CalendarsSharedWith(p.Username) + if err != nil { + b.logger.Warn("listing shared calendars", "error", err) + } + for _, sh := range shares { + if _, err := b.store.GetCollection(sh.Owner, "cal-"+sh.CalendarName); err != nil { + continue // owner's calendar no longer exists + } + localName := sharedCalendarName(sh.Owner, sh.CalendarName) + cals = append(cals, b.calendarMeta(sh.Owner, sh.CalendarName, localName)) } } @@ -94,24 +119,32 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) } func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Calendar, error) { - user, name, err := b.parseCalPath(ctx, calPath) + requester, localName, err := b.parseCalPath(ctx, calPath) if err != nil { return nil, err } - if _, err := b.store.GetCollection(user, "cal-"+name); err != nil { + owner, realName, _, err := b.resolveCalendar(requester, localName, false) + if err != nil { + return nil, err + } + if _, err := b.store.GetCollection(owner, "cal-"+realName); err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } - cal := b.calendarMeta(user, name) + cal := b.calendarMeta(owner, realName, localName) return &cal, nil } func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { - user, calName, objID, err := b.parseObjPath(ctx, objPath) + requester, localName, objID, err := b.parseObjPath(ctx, objPath) + if err != nil { + return nil, err + } + owner, realName, _, err := b.resolveCalendar(requester, localName, false) if err != nil { return nil, err } - data, err := b.store.GetObject(user, "cal-"+calName, objID) + data, err := b.store.GetObject(owner, "cal-"+realName, objID) if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } @@ -120,23 +153,27 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca } func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { - user, calName, err := b.parseCalPath(ctx, calPath) + requester, localName, err := b.parseCalPath(ctx, calPath) + if err != nil { + return nil, err + } + owner, realName, _, err := b.resolveCalendar(requester, localName, false) if err != nil { return nil, err } - ids, err := b.store.ListObjects(user, "cal-"+calName) + ids, err := b.store.ListObjects(owner, "cal-"+realName) if err != nil { return nil, err } var objs []caldav.CalendarObject for _, id := range ids { - data, err := b.store.GetObject(user, "cal-"+calName, id) + data, err := b.store.GetObject(owner, "cal-"+realName, id) if err != nil { continue } - obj, err := b.decodeObject(calObjectPath(calName, id), data) + obj, err := b.decodeObject(calObjectPath(localName, id), data) if err != nil { b.logger.Warn("decoding calendar object", "id", id, "error", err) continue @@ -160,21 +197,32 @@ func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) if p == nil { return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } - // Derive collection name from the trailing path segment. + // Derive collection name from the trailing path segment. Calendars are + // always created under the acting user's own namespace — sharing an + // existing calendar is done via ShareCalendar, not by creating one + // directly in someone else's name. name := path.Base(strings.TrimSuffix(calendar.Path, "/")) return b.store.EnsureCollection(p.Username, "cal-"+name) } func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error { - user, name, err := b.parseCalPath(ctx, calPath) + requester, localName, err := b.parseCalPath(ctx, calPath) if err != nil { return err } - return b.store.DeleteCollection(user, "cal-"+name) + owner, realName, _, err := b.resolveCalendar(requester, localName, true) + if err != nil { + return err + } + return b.store.DeleteCollection(owner, "cal-"+realName) } func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { - user, calName, objID, err := b.parseObjPath(ctx, objPath) + requester, localName, objID, err := b.parseObjPath(ctx, objPath) + if err != nil { + return nil, err + } + owner, realName, _, err := b.resolveCalendar(requester, localName, true) if err != nil { return nil, err } @@ -186,7 +234,7 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda } data := []byte(buf.String()) - if err := b.store.PutObject(user, "cal-"+calName, objID, data); err != nil { + if err := b.store.PutObject(owner, "cal-"+realName, objID, data); err != nil { return nil, fmt.Errorf("storing calendar object: %w", err) } @@ -194,11 +242,15 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda } func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) error { - user, calName, objID, err := b.parseObjPath(ctx, objPath) + requester, localName, objID, err := b.parseObjPath(ctx, objPath) if err != nil { return err } - if err := b.store.DeleteObject(user, "cal-"+calName, objID); err != nil { + owner, realName, _, err := b.resolveCalendar(requester, localName, true) + if err != nil { + return err + } + if err := b.store.DeleteObject(owner, "cal-"+realName, objID); err != nil { return webdav.NewHTTPError(http.StatusNotFound, err) } return nil @@ -206,11 +258,44 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro // -------- helpers -------- -func (b *Backend) calendarMeta(user, name string) caldav.Calendar { +// sharedCalendarName builds the synthetic local name a shared calendar is +// exposed under to the user it was shared with. +func sharedCalendarName(owner, calName string) string { + return owner + sharedNameSep + calName +} + +// resolveCalendar maps a local calendar name (as seen in a URL path by +// requester) to its real owner and on-disk name, checking permissions +// along the way. If localName contains sharedNameSep, it's treated as a +// reference to another user's calendar and looked up in the shares table; +// otherwise it's assumed to be one of requester's own calendars. +// +// If requireWrite is true, a share must grant PermWrite or this returns a +// 403 Forbidden error. Owners always have full access to their own +// calendars. +func (b *Backend) resolveCalendar(requester, localName string, requireWrite bool) (owner, realName string, perm db.Permission, err error) { + if ownerName, calName, ok := strings.Cut(localName, sharedNameSep); ok { + if b.dbase == nil { + return "", "", "", webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("sharing not enabled")) + } + share, err := b.dbase.CalendarShareFor(ownerName, calName, requester) + if err != nil { + return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("calendar not shared with you")) + } + if requireWrite && share.Permission != db.PermWrite { + return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("read-only share")) + } + return ownerName, calName, share.Permission, nil + } + return requester, localName, db.PermWrite, nil +} + +func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calendar { + desc := fmt.Sprintf("%s's %s calendar", owner, realName) return caldav.Calendar{ - Path: calHomePath() + name + "/", - Name: name, - Description: fmt.Sprintf("%s's %s calendar", user, name), + Path: calHomePath() + localName + "/", + Name: localName, + Description: desc, SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"}, MaxResourceSize: 10 * 1024 * 1024, // 10 MiB } diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go new file mode 100644 index 0000000..9558fc8 --- /dev/null +++ b/internal/caldav/backend_test.go @@ -0,0 +1,144 @@ +package caldav + +import ( + "context" + "fmt" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + + ical "github.com/emersion/go-ical" + "github.com/yourusername/caldav-server/internal/auth" + "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/store" +) + +func newTestBackend(t *testing.T) (*Backend, *db.DB) { + t.Helper() + dir := t.TempDir() + + st, err := store.NewStore(filepath.Join(dir, "data")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + dbase, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { dbase.Close() }) + + cfg := &config.Config{ + Users: map[string]config.UserConfig{ + "alice": {Calendars: []string{"work"}}, + "bob": {Calendars: []string{"personal"}}, + }, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewBackend(cfg, st, dbase, logger), dbase +} + +func ctxFor(username string) context.Context { + return auth.NewContext(context.Background(), &auth.Principal{Username: username}) +} + +// minimalEvent returns a minimal, valid VCALENDAR/VEVENT for use in tests. +func minimalEvent() *ical.Calendar { + const raw = "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//nidus//test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:event1@nidus.test\r\n" + + "DTSTAMP:20240101T000000Z\r\n" + + "DTSTART:20240101T100000Z\r\n" + + "SUMMARY:Test Event\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode() + if err != nil { + panic(fmt.Sprintf("minimalEvent: %v", err)) + } + return cal +} + +func TestListCalendarsIncludesSharedCalendar(t *testing.T) { + b, dbase := newTestBackend(t) + + if err := dbase.ShareCalendar("alice", "work", "bob", db.PermRead); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + // The shared calendar must exist on disk for it to be listed; normally + // this happens when alice's own ListCalendars runs and ensures it. + if _, err := b.ListCalendars(ctxFor("alice")); err != nil { + t.Fatalf("ListCalendars(alice): %v", err) + } + + cals, err := b.ListCalendars(ctxFor("bob")) + if err != nil { + t.Fatalf("ListCalendars: %v", err) + } + + var found bool + wantName := sharedCalendarName("alice", "work") + for _, c := range cals { + if c.Name == wantName { + found = true + } + } + if !found { + t.Errorf("shared calendar %q not found in ListCalendars result: %+v", wantName, cals) + } +} + +func TestSharedCalendarReadOnlyRejectsWrite(t *testing.T) { + b, dbase := newTestBackend(t) + + if err := dbase.ShareCalendar("alice", "work", "bob", db.PermRead); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + + localName := sharedCalendarName("alice", "work") + objPath := calObjectPath(localName, "event1.ics") + + _, err := b.PutCalendarObject(ctxFor("bob"), objPath, minimalEvent(), nil) + if err == nil { + t.Fatal("expected error writing to read-only shared calendar, got nil") + } +} + +func TestSharedCalendarWriteAllowed(t *testing.T) { + b, dbase := newTestBackend(t) + + if err := dbase.ShareCalendar("alice", "work", "bob", db.PermWrite); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + + localName := sharedCalendarName("alice", "work") + objPath := calObjectPath(localName, "event1.ics") + + if _, err := b.PutCalendarObject(ctxFor("bob"), objPath, minimalEvent(), nil); err != nil { + t.Fatalf("PutCalendarObject with write share: %v", err) + } + + // The object should now be visible under alice's own calendar too, + // since it's stored in her namespace. + obj, err := b.GetCalendarObject(ctxFor("alice"), calObjectPath("work", "event1.ics"), nil) + if err != nil { + t.Fatalf("GetCalendarObject as owner: %v", err) + } + if obj == nil { + t.Fatal("expected non-nil object") + } +} + +func TestUnauthorizedUserCannotAccessUnsharedCalendar(t *testing.T) { + b, _ := newTestBackend(t) + + localName := sharedCalendarName("alice", "work") + _, err := b.GetCalendar(ctxFor("bob"), calHomePath()+localName+"/") + if err == nil { + t.Fatal("expected error accessing unshared calendar, got nil") + } +} diff --git a/internal/carddav/backend.go b/internal/carddav/backend.go index c310f60..df18877 100644 --- a/internal/carddav/backend.go +++ b/internal/carddav/backend.go @@ -14,24 +14,32 @@ import ( "github.com/emersion/go-webdav/carddav" "github.com/yourusername/caldav-server/internal/auth" "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/store" ) +// sharedNameSep separates the owner from the address book name in the +// synthetic name used for address books shared with another user (see +// caldav.sharedNameSep, kept identical for consistency). +const sharedNameSep = "~" + // Backend implements carddav.Backend using a filesystem store. type Backend struct { cfg *config.Config store *store.Store + dbase *db.DB // may be nil if sharing is not configured logger *slog.Logger } -// NewBackend creates a CardDAV backend. -func NewBackend(cfg *config.Config, st *store.Store, logger *slog.Logger) *Backend { - return &Backend{cfg: cfg, store: st, logger: logger} +// NewBackend creates a CardDAV backend. dbase may be nil, in which case +// address book sharing is disabled (only a user's own books are visible). +func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend { + return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger} } // NewHandler returns an http.Handler for the /card/ prefix. -func NewHandler(cfg *config.Config, st *store.Store, logger *slog.Logger) http.Handler { - b := NewBackend(cfg, st, logger) +func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler { + b := NewBackend(cfg, st, dbase, logger) return &carddav.Handler{Backend: b} } @@ -70,7 +78,7 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, b.logger.Warn("ensuring address book directory", "book", name, "error", err) continue } - books = append(books, b.bookMeta(p.Username, name)) + books = append(books, b.bookMeta(p.Username, name, name)) } // Also include extra books that exist on disk @@ -82,7 +90,22 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, for _, dir := range disk { if strings.HasPrefix(dir, "card-") && !configured[dir] { name := strings.TrimPrefix(dir, "card-") - books = append(books, b.bookMeta(p.Username, name)) + books = append(books, b.bookMeta(p.Username, name, name)) + } + } + + // Include address books other users have shared with this one. + if b.dbase != nil { + shares, err := b.dbase.AddressBooksSharedWith(p.Username) + if err != nil { + b.logger.Warn("listing shared address books", "error", err) + } + for _, sh := range shares { + if _, err := b.store.GetCollection(sh.Owner, "card-"+sh.AddressBookName); err != nil { + continue // owner's address book no longer exists + } + localName := sharedBookName(sh.Owner, sh.AddressBookName) + books = append(books, b.bookMeta(sh.Owner, sh.AddressBookName, localName)) } } @@ -90,24 +113,32 @@ func (b *Backend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, } func (b *Backend) GetAddressBook(ctx context.Context, bookPath string) (*carddav.AddressBook, error) { - user, name, err := b.parseBookPath(ctx, bookPath) + requester, localName, err := b.parseBookPath(ctx, bookPath) if err != nil { return nil, err } - if _, err := b.store.GetCollection(user, "card-"+name); err != nil { + owner, realName, _, err := b.resolveBook(requester, localName, false) + if err != nil { + return nil, err + } + if _, err := b.store.GetCollection(owner, "card-"+realName); err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } - book := b.bookMeta(user, name) + book := b.bookMeta(owner, realName, localName) return &book, nil } func (b *Backend) GetAddressObject(ctx context.Context, objPath string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) { - user, bookName, objID, err := b.parseObjPath(ctx, objPath) + requester, localName, objID, err := b.parseObjPath(ctx, objPath) + if err != nil { + return nil, err + } + owner, realName, _, err := b.resolveBook(requester, localName, false) if err != nil { return nil, err } - data, err := b.store.GetObject(user, "card-"+bookName, objID) + data, err := b.store.GetObject(owner, "card-"+realName, objID) if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } @@ -116,23 +147,27 @@ func (b *Backend) GetAddressObject(ctx context.Context, objPath string, req *car } func (b *Backend) ListAddressObjects(ctx context.Context, bookPath string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) { - user, bookName, err := b.parseBookPath(ctx, bookPath) + requester, localName, err := b.parseBookPath(ctx, bookPath) + if err != nil { + return nil, err + } + owner, realName, _, err := b.resolveBook(requester, localName, false) if err != nil { return nil, err } - ids, err := b.store.ListObjects(user, "card-"+bookName) + ids, err := b.store.ListObjects(owner, "card-"+realName) if err != nil { return nil, err } var objs []carddav.AddressObject for _, id := range ids { - data, err := b.store.GetObject(user, "card-"+bookName, id) + data, err := b.store.GetObject(owner, "card-"+realName, id) if err != nil { continue } - obj, err := b.decodeObject(cardObjectPath(bookName, id), data) + obj, err := b.decodeObject(cardObjectPath(localName, id), data) if err != nil { b.logger.Warn("decoding vcard object", "id", id, "error", err) continue @@ -160,15 +195,23 @@ func (b *Backend) CreateAddressBook(ctx context.Context, book *carddav.AddressBo } func (b *Backend) DeleteAddressBook(ctx context.Context, bookPath string) error { - user, name, err := b.parseBookPath(ctx, bookPath) + requester, localName, err := b.parseBookPath(ctx, bookPath) if err != nil { return err } - return b.store.DeleteCollection(user, "card-"+name) + owner, realName, _, err := b.resolveBook(requester, localName, true) + if err != nil { + return err + } + return b.store.DeleteCollection(owner, "card-"+realName) } func (b *Backend) PutAddressObject(ctx context.Context, objPath string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) { - user, bookName, objID, err := b.parseObjPath(ctx, objPath) + requester, localName, objID, err := b.parseObjPath(ctx, objPath) + if err != nil { + return nil, err + } + owner, realName, _, err := b.resolveBook(requester, localName, true) if err != nil { return nil, err } @@ -180,7 +223,7 @@ func (b *Backend) PutAddressObject(ctx context.Context, objPath string, card vca } data := []byte(buf.String()) - if err := b.store.PutObject(user, "card-"+bookName, objID, data); err != nil { + if err := b.store.PutObject(owner, "card-"+realName, objID, data); err != nil { return nil, fmt.Errorf("storing address object: %w", err) } @@ -188,11 +231,15 @@ func (b *Backend) PutAddressObject(ctx context.Context, objPath string, card vca } func (b *Backend) DeleteAddressObject(ctx context.Context, objPath string) error { - user, bookName, objID, err := b.parseObjPath(ctx, objPath) + requester, localName, objID, err := b.parseObjPath(ctx, objPath) if err != nil { return err } - if err := b.store.DeleteObject(user, "card-"+bookName, objID); err != nil { + owner, realName, _, err := b.resolveBook(requester, localName, true) + if err != nil { + return err + } + if err := b.store.DeleteObject(owner, "card-"+realName, objID); err != nil { return webdav.NewHTTPError(http.StatusNotFound, err) } return nil @@ -200,11 +247,37 @@ func (b *Backend) DeleteAddressObject(ctx context.Context, objPath string) error // -------- helpers -------- -func (b *Backend) bookMeta(user, name string) carddav.AddressBook { +// sharedBookName builds the synthetic local name a shared address book is +// exposed under to the user it was shared with. +func sharedBookName(owner, bookName string) string { + return owner + sharedNameSep + bookName +} + +// resolveBook maps a local address book name (as seen in a URL path by +// requester) to its real owner and on-disk name, checking permissions +// along the way. Mirrors caldav.Backend.resolveCalendar. +func (b *Backend) resolveBook(requester, localName string, requireWrite bool) (owner, realName string, perm db.Permission, err error) { + if ownerName, bookName, ok := strings.Cut(localName, sharedNameSep); ok { + if b.dbase == nil { + return "", "", "", webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("sharing not enabled")) + } + share, err := b.dbase.AddressBookShareFor(ownerName, bookName, requester) + if err != nil { + return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("address book not shared with you")) + } + if requireWrite && share.Permission != db.PermWrite { + return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("read-only share")) + } + return ownerName, bookName, share.Permission, nil + } + return requester, localName, db.PermWrite, nil +} + +func (b *Backend) bookMeta(owner, realName, localName string) carddav.AddressBook { return carddav.AddressBook{ - Path: cardHomePath() + name + "/", - Name: name, - Description: fmt.Sprintf("%s's %s address book", user, name), + Path: cardHomePath() + localName + "/", + Name: localName, + Description: fmt.Sprintf("%s's %s address book", owner, realName), MaxResourceSize: 10 * 1024 * 1024, } } diff --git a/internal/carddav/backend_test.go b/internal/carddav/backend_test.go new file mode 100644 index 0000000..7b6f579 --- /dev/null +++ b/internal/carddav/backend_test.go @@ -0,0 +1,138 @@ +package carddav + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + + vcard "github.com/emersion/go-vcard" + "github.com/yourusername/caldav-server/internal/auth" + "github.com/yourusername/caldav-server/internal/config" + "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/store" +) + +func newTestBackend(t *testing.T) (*Backend, *db.DB) { + t.Helper() + dir := t.TempDir() + + st, err := store.NewStore(filepath.Join(dir, "data")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + dbase, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { dbase.Close() }) + + cfg := &config.Config{ + Users: map[string]config.UserConfig{ + "alice": {AddressBooks: []string{"contacts"}}, + "bob": {AddressBooks: []string{"personal"}}, + }, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewBackend(cfg, st, dbase, logger), dbase +} + +func ctxFor(username string) context.Context { + return auth.NewContext(context.Background(), &auth.Principal{Username: username}) +} + +// minimalCard returns a minimal, valid vCard for use in tests. +func minimalCard() vcard.Card { + const raw = "BEGIN:VCARD\r\n" + + "VERSION:3.0\r\n" + + "UID:card1@nidus.test\r\n" + + "FN:Test Person\r\n" + + "END:VCARD\r\n" + card, err := vcard.NewDecoder(strings.NewReader(raw)).Decode() + if err != nil { + panic("minimalCard: " + err.Error()) + } + return card +} + +func TestListAddressBooksIncludesSharedBook(t *testing.T) { + b, dbase := newTestBackend(t) + + if err := dbase.ShareAddressBook("alice", "contacts", "bob", db.PermRead); err != nil { + t.Fatalf("ShareAddressBook: %v", err) + } + // The shared book must exist on disk for it to be listed; normally this + // happens when alice's own ListAddressBooks runs and ensures it. + if _, err := b.ListAddressBooks(ctxFor("alice")); err != nil { + t.Fatalf("ListAddressBooks(alice): %v", err) + } + + books, err := b.ListAddressBooks(ctxFor("bob")) + if err != nil { + t.Fatalf("ListAddressBooks: %v", err) + } + + var found bool + wantName := sharedBookName("alice", "contacts") + for _, book := range books { + if book.Name == wantName { + found = true + } + } + if !found { + t.Errorf("shared address book %q not found in ListAddressBooks result: %+v", wantName, books) + } +} + +func TestSharedAddressBookReadOnlyRejectsWrite(t *testing.T) { + b, dbase := newTestBackend(t) + + if err := dbase.ShareAddressBook("alice", "contacts", "bob", db.PermRead); err != nil { + t.Fatalf("ShareAddressBook: %v", err) + } + + localName := sharedBookName("alice", "contacts") + objPath := cardObjectPath(localName, "card1.vcf") + + _, err := b.PutAddressObject(ctxFor("bob"), objPath, minimalCard(), nil) + if err == nil { + t.Fatal("expected error writing to read-only shared address book, got nil") + } +} + +func TestSharedAddressBookWriteAllowed(t *testing.T) { + b, dbase := newTestBackend(t) + + if err := dbase.ShareAddressBook("alice", "contacts", "bob", db.PermWrite); err != nil { + t.Fatalf("ShareAddressBook: %v", err) + } + + localName := sharedBookName("alice", "contacts") + objPath := cardObjectPath(localName, "card1.vcf") + + if _, err := b.PutAddressObject(ctxFor("bob"), objPath, minimalCard(), nil); err != nil { + t.Fatalf("PutAddressObject with write share: %v", err) + } + + // The object should now be visible under alice's own address book too, + // since it's stored in her namespace. + obj, err := b.GetAddressObject(ctxFor("alice"), cardObjectPath("contacts", "card1.vcf"), nil) + if err != nil { + t.Fatalf("GetAddressObject as owner: %v", err) + } + if obj == nil { + t.Fatal("expected non-nil object") + } +} + +func TestUnauthorizedUserCannotAccessUnsharedBook(t *testing.T) { + b, _ := newTestBackend(t) + + localName := sharedBookName("alice", "contacts") + _, err := b.GetAddressBook(ctxFor("bob"), cardHomePath()+localName+"/") + if err == nil { + t.Fatal("expected error accessing unshared address book, got nil") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f81de5d --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,69 @@ +// Package db provides a lightweight SQLite-backed store for data that +// doesn't fit the plain-file model used by internal/store — currently +// calendar/address-book sharing grants. It's intentionally small: no ORM, +// just database/sql with hand-written queries, so it stays easy to +// extend when user management and the web UI are added later. +package db + +import ( + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +// DB wraps a SQLite connection and exposes typed helpers for the +// application's tables. +type DB struct { + conn *sql.DB +} + +// Open opens (creating if necessary) the SQLite database at path and runs +// schema migrations. +func Open(path string) (*DB, error) { + conn, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("opening database %q: %w", path, err) + } + // SQLite only supports one writer at a time; a single connection avoids + // "database is locked" errors under concurrent access. + conn.SetMaxOpenConns(1) + + d := &DB{conn: conn} + if err := d.migrate(); err != nil { + conn.Close() + return nil, fmt.Errorf("migrating database: %w", err) + } + return d, nil +} + +// Close closes the underlying connection. +func (d *DB) Close() error { + return d.conn.Close() +} + +func (d *DB) migrate() error { + const schema = ` +CREATE TABLE IF NOT EXISTS calendar_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + calendar_name TEXT NOT NULL, + shared_with TEXT NOT NULL, + permission TEXT NOT NULL CHECK (permission IN ('read', 'write')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (owner, calendar_name, shared_with) +); + +CREATE TABLE IF NOT EXISTS addressbook_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + addressbook_name TEXT NOT NULL, + shared_with TEXT NOT NULL, + permission TEXT NOT NULL CHECK (permission IN ('read', 'write')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (owner, addressbook_name, shared_with) +); +` + _, err := d.conn.Exec(schema) + return err +} diff --git a/internal/db/shares.go b/internal/db/shares.go new file mode 100644 index 0000000..a883540 --- /dev/null +++ b/internal/db/shares.go @@ -0,0 +1,227 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// Permission is the access level granted by a share. +type Permission string + +const ( + PermRead Permission = "read" + PermWrite Permission = "write" +) + +// ErrShareNotFound is returned when revoking a share that doesn't exist. +var ErrShareNotFound = errors.New("share not found") + +// CalendarShare describes a grant of access to owner's calendar to another +// user. +type CalendarShare struct { + Owner string + CalendarName string + SharedWith string + Permission Permission +} + +// ShareCalendar grants sharedWith access (read or write) to owner's +// calendar calName. Calling it again for the same (owner, calName, +// sharedWith) updates the permission. +func (d *DB) ShareCalendar(owner, calName, sharedWith string, perm Permission) error { + if perm != PermRead && perm != PermWrite { + return fmt.Errorf("invalid permission %q", perm) + } + _, err := d.conn.Exec(` + INSERT INTO calendar_shares (owner, calendar_name, shared_with, permission) + VALUES (?, ?, ?, ?) + ON CONFLICT (owner, calendar_name, shared_with) + DO UPDATE SET permission = excluded.permission`, + owner, calName, sharedWith, string(perm)) + if err != nil { + return fmt.Errorf("sharing calendar: %w", err) + } + return nil +} + +// UnshareCalendar revokes sharedWith's access to owner's calendar calName. +func (d *DB) UnshareCalendar(owner, calName, sharedWith string) error { + res, err := d.conn.Exec(` + DELETE FROM calendar_shares + WHERE owner = ? AND calendar_name = ? AND shared_with = ?`, + owner, calName, sharedWith) + if err != nil { + return fmt.Errorf("unsharing calendar: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("unsharing calendar: %w", err) + } + if n == 0 { + return ErrShareNotFound + } + return nil +} + +// SharesOfCalendar lists everyone owner's calendar calName has been shared +// with. +func (d *DB) SharesOfCalendar(owner, calName string) ([]CalendarShare, error) { + rows, err := d.conn.Query(` + SELECT owner, calendar_name, shared_with, permission + FROM calendar_shares + WHERE owner = ? AND calendar_name = ? + ORDER BY shared_with`, + owner, calName) + if err != nil { + return nil, fmt.Errorf("listing calendar shares: %w", err) + } + defer rows.Close() + return scanCalendarShares(rows) +} + +// CalendarsSharedWith lists all calendars (from any owner) that have been +// shared with user. +func (d *DB) CalendarsSharedWith(user string) ([]CalendarShare, error) { + rows, err := d.conn.Query(` + SELECT owner, calendar_name, shared_with, permission + FROM calendar_shares + WHERE shared_with = ? + ORDER BY owner, calendar_name`, + user) + if err != nil { + return nil, fmt.Errorf("listing calendars shared with user: %w", err) + } + defer rows.Close() + return scanCalendarShares(rows) +} + +// CalendarShare looks up the share record granting user access to +// owner's calendar calName, if any. +func (d *DB) CalendarShareFor(owner, calName, user string) (*CalendarShare, error) { + row := d.conn.QueryRow(` + SELECT owner, calendar_name, shared_with, permission + FROM calendar_shares + WHERE owner = ? AND calendar_name = ? AND shared_with = ?`, + owner, calName, user) + + var s CalendarShare + var perm string + if err := row.Scan(&s.Owner, &s.CalendarName, &s.SharedWith, &perm); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrShareNotFound + } + return nil, fmt.Errorf("looking up calendar share: %w", err) + } + s.Permission = Permission(perm) + return &s, nil +} + +func scanCalendarShares(rows *sql.Rows) ([]CalendarShare, error) { + var shares []CalendarShare + for rows.Next() { + var s CalendarShare + var perm string + if err := rows.Scan(&s.Owner, &s.CalendarName, &s.SharedWith, &perm); err != nil { + return nil, fmt.Errorf("scanning calendar share: %w", err) + } + s.Permission = Permission(perm) + shares = append(shares, s) + } + return shares, rows.Err() +} + +// AddressBookShare describes a grant of access to owner's address book to +// another user. +type AddressBookShare struct { + Owner string + AddressBookName string + SharedWith string + Permission Permission +} + +// ShareAddressBook grants sharedWith access (read or write) to owner's +// address book bookName. +func (d *DB) ShareAddressBook(owner, bookName, sharedWith string, perm Permission) error { + if perm != PermRead && perm != PermWrite { + return fmt.Errorf("invalid permission %q", perm) + } + _, err := d.conn.Exec(` + INSERT INTO addressbook_shares (owner, addressbook_name, shared_with, permission) + VALUES (?, ?, ?, ?) + ON CONFLICT (owner, addressbook_name, shared_with) + DO UPDATE SET permission = excluded.permission`, + owner, bookName, sharedWith, string(perm)) + if err != nil { + return fmt.Errorf("sharing address book: %w", err) + } + return nil +} + +// UnshareAddressBook revokes sharedWith's access to owner's address book +// bookName. +func (d *DB) UnshareAddressBook(owner, bookName, sharedWith string) error { + res, err := d.conn.Exec(` + DELETE FROM addressbook_shares + WHERE owner = ? AND addressbook_name = ? AND shared_with = ?`, + owner, bookName, sharedWith) + if err != nil { + return fmt.Errorf("unsharing address book: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("unsharing address book: %w", err) + } + if n == 0 { + return ErrShareNotFound + } + return nil +} + +// AddressBooksSharedWith lists all address books (from any owner) that +// have been shared with user. +func (d *DB) AddressBooksSharedWith(user string) ([]AddressBookShare, error) { + rows, err := d.conn.Query(` + SELECT owner, addressbook_name, shared_with, permission + FROM addressbook_shares + WHERE shared_with = ? + ORDER BY owner, addressbook_name`, + user) + if err != nil { + return nil, fmt.Errorf("listing address books shared with user: %w", err) + } + defer rows.Close() + + var shares []AddressBookShare + for rows.Next() { + var s AddressBookShare + var perm string + if err := rows.Scan(&s.Owner, &s.AddressBookName, &s.SharedWith, &perm); err != nil { + return nil, fmt.Errorf("scanning address book share: %w", err) + } + s.Permission = Permission(perm) + shares = append(shares, s) + } + return shares, rows.Err() +} + +// AddressBookShareFor looks up the share record granting user access to +// owner's address book bookName, if any. +func (d *DB) AddressBookShareFor(owner, bookName, user string) (*AddressBookShare, error) { + row := d.conn.QueryRow(` + SELECT owner, addressbook_name, shared_with, permission + FROM addressbook_shares + WHERE owner = ? AND addressbook_name = ? AND shared_with = ?`, + owner, bookName, user) + + var s AddressBookShare + var perm string + if err := row.Scan(&s.Owner, &s.AddressBookName, &s.SharedWith, &perm); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrShareNotFound + } + return nil, fmt.Errorf("looking up address book share: %w", err) + } + s.Permission = Permission(perm) + return &s, nil +} diff --git a/internal/db/shares_test.go b/internal/db/shares_test.go new file mode 100644 index 0000000..69cef47 --- /dev/null +++ b/internal/db/shares_test.go @@ -0,0 +1,154 @@ +package db + +import ( + "errors" + "path/filepath" + "testing" +) + +func openTestDB(t *testing.T) *DB { + t.Helper() + dbase, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { dbase.Close() }) + return dbase +} + +func TestShareCalendarAndLookup(t *testing.T) { + dbase := openTestDB(t) + + if err := dbase.ShareCalendar("alice", "work", "bob", PermRead); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + + share, err := dbase.CalendarShareFor("alice", "work", "bob") + if err != nil { + t.Fatalf("CalendarShareFor: %v", err) + } + if share.Permission != PermRead { + t.Errorf("Permission = %q, want %q", share.Permission, PermRead) + } + + // Re-sharing with a different permission updates in place rather than + // erroring or duplicating. + if err := dbase.ShareCalendar("alice", "work", "bob", PermWrite); err != nil { + t.Fatalf("ShareCalendar (update): %v", err) + } + share, err = dbase.CalendarShareFor("alice", "work", "bob") + if err != nil { + t.Fatalf("CalendarShareFor after update: %v", err) + } + if share.Permission != PermWrite { + t.Errorf("Permission after update = %q, want %q", share.Permission, PermWrite) + } + + shares, err := dbase.SharesOfCalendar("alice", "work") + if err != nil { + t.Fatalf("SharesOfCalendar: %v", err) + } + if len(shares) != 1 || shares[0].SharedWith != "bob" { + t.Errorf("SharesOfCalendar = %+v, want single share with bob", shares) + } +} + +func TestCalendarShareForNotFound(t *testing.T) { + dbase := openTestDB(t) + + _, err := dbase.CalendarShareFor("alice", "work", "bob") + if !errors.Is(err, ErrShareNotFound) { + t.Errorf("err = %v, want ErrShareNotFound", err) + } +} + +func TestUnshareCalendar(t *testing.T) { + dbase := openTestDB(t) + + if err := dbase.ShareCalendar("alice", "work", "bob", PermRead); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + if err := dbase.UnshareCalendar("alice", "work", "bob"); err != nil { + t.Fatalf("UnshareCalendar: %v", err) + } + if _, err := dbase.CalendarShareFor("alice", "work", "bob"); !errors.Is(err, ErrShareNotFound) { + t.Errorf("share still present after unshare: err = %v", err) + } + + // Unsharing a non-existent share reports ErrShareNotFound. + if err := dbase.UnshareCalendar("alice", "work", "bob"); !errors.Is(err, ErrShareNotFound) { + t.Errorf("UnshareCalendar (already gone) = %v, want ErrShareNotFound", err) + } +} + +func TestCalendarsSharedWith(t *testing.T) { + dbase := openTestDB(t) + + if err := dbase.ShareCalendar("alice", "work", "bob", PermRead); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + if err := dbase.ShareCalendar("carol", "family", "bob", PermWrite); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + // A share for a different user shouldn't show up for bob. + if err := dbase.ShareCalendar("alice", "personal", "dave", PermRead); err != nil { + t.Fatalf("ShareCalendar: %v", err) + } + + shares, err := dbase.CalendarsSharedWith("bob") + if err != nil { + t.Fatalf("CalendarsSharedWith: %v", err) + } + if len(shares) != 2 { + t.Fatalf("len(shares) = %d, want 2", len(shares)) + } +} + +func TestShareCalendarInvalidPermission(t *testing.T) { + dbase := openTestDB(t) + if err := dbase.ShareCalendar("alice", "work", "bob", Permission("admin")); err == nil { + t.Error("expected error for invalid permission, got nil") + } +} + +func TestShareAddressBookAndLookup(t *testing.T) { + dbase := openTestDB(t) + + if err := dbase.ShareAddressBook("alice", "contacts", "bob", PermRead); err != nil { + t.Fatalf("ShareAddressBook: %v", err) + } + + share, err := dbase.AddressBookShareFor("alice", "contacts", "bob") + if err != nil { + t.Fatalf("AddressBookShareFor: %v", err) + } + if share.Permission != PermRead { + t.Errorf("Permission = %q, want %q", share.Permission, PermRead) + } + + if err := dbase.UnshareAddressBook("alice", "contacts", "bob"); err != nil { + t.Fatalf("UnshareAddressBook: %v", err) + } + if _, err := dbase.AddressBookShareFor("alice", "contacts", "bob"); !errors.Is(err, ErrShareNotFound) { + t.Errorf("share still present after unshare: err = %v", err) + } +} + +func TestAddressBooksSharedWith(t *testing.T) { + dbase := openTestDB(t) + + if err := dbase.ShareAddressBook("alice", "contacts", "bob", PermRead); err != nil { + t.Fatalf("ShareAddressBook: %v", err) + } + if err := dbase.ShareAddressBook("carol", "friends", "bob", PermWrite); err != nil { + t.Fatalf("ShareAddressBook: %v", err) + } + + shares, err := dbase.AddressBooksSharedWith("bob") + if err != nil { + t.Fatalf("AddressBooksSharedWith: %v", err) + } + if len(shares) != 2 { + t.Fatalf("len(shares) = %d, want 2", len(shares)) + } +}