Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
the link-only fallback that needs no Zoom app. Answers
[#35](https://github.com/Calnode/calnode/issues/35).

- **A CalDAV account now offers every calendar in it, not only the one it connected with.**
Closes [#42](https://github.com/Calnode/calnode/issues/42).

Connecting binds one collection, and the calendar picker could only ever show that one, so
an account with several calendars (the report was Synology Calendar) could be checked and
booked into through its default calendar alone. The picker now lists every calendar under
the account's calendar home that can hold events, marks read-only shares (from
`DAV:current-user-privilege-set`) so they can be checked but not booked into, and free/busy
reads every calendar ticked for conflicts. Accounts that never saved a selection keep
reading only the calendar they were bound to, and nothing needs reconnecting: the list is
rediscovered from the stored calendar URL. The picker also says what Check and Book mean,
and why Book moves rather than unticks.

Two refusals come with it, both CalDAV-only. A calendar listed on a different origin from
its calendar home is skipped, because a CalDAV calendar id is the URL that later receives the
account's credentials; listing an existing account's calendars goes further and never sends a
request, or follows a principal, calendar home or redirect, off the origin of the calendar it
connected with. This changes connecting for one setup: a server behind a reverse proxy
that reports its internal scheme or host in every href used to connect and now does not, with
an error naming both addresses and pointing at the proxy or base URL settings. And saving a
selection that names a CalDAV calendar the server did not list is refused with a 400, where
the endpoint used to store whatever the client sent. Google and Microsoft selections save
exactly as before, with no call to the provider: their ids carry no credentials anywhere, so
there is nothing for the check to protect.

## [0.9.0] - 2026-09-10

### Added
Expand Down
34 changes: 34 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,31 @@ Calnode talks to calendars through a **provider abstraction**, not a single vend
added via the `openid` scope. A blanket `404 MailboxNotEnabledForRESTAPI` on every
`/me/*` call means the account has **no Exchange Online mailbox** (not an auth error).
Graph errors log their response body to make this self-evident.
- *CalDAV* (`internal/caldav`): username + app password over Basic auth, connected through
`POST /v1/calendar/caldav/connect`, which walks `current-user-principal` →
`calendar-home-set` → a Depth 1 listing and binds the account's default collection
(`calendar_connections.calendar_id`). **A calendar id is the collection URL**, so the picker,
free/busy and write-back all address collections directly. `ListCalendars` repeats that walk
from the bound collection with the stored credentials (the server URL typed at connect is not
stored; a server that reports no principal there gets the collection's parent) and lists
every VEVENT-capable collection under the home, with `Writable` read from
`DAV:current-user-privilege-set` (`all`, `write`, `write-content` or `bind`; not reported
counts as writable). **A collection on a different origin (scheme, host, port) from the home
is skipped**, at connect and when listing, because a stored id is sent the account's
credentials on every read and write. When that leaves connect with no event calendar at all
(typically a reverse proxy whose upstream reports its internal scheme or host in hrefs), it
fails naming both addresses rather than with "no writable calendar found". CalDAV implements
`calendar.SelectionValidator` (below). Free/busy REPORTs each calendar ticked for conflicts
(`ConflictCalendarIDs`) and makes no discovery requests; an account that never saved a
selection reads only its bound collection. A 401/403 wraps `calendar.ErrReauthRequired`, so
a revoked app password shows as "needs reconnecting" in the picker.
**Listing never leaves the bound collection's origin.** It runs on every picker load and
every saved selection, with the account's credentials, against hrefs the server chooses, so
`homeForCalendar` does not follow a principal or calendar home on another origin (it lists the
bound collection's parent instead), `propfind` is pinned to that origin and refuses a redirect
off it, and `ListCalendars` drops any collection off it. Connect-time discovery is not pinned:
it starts from a URL the user typed, and can legitimately cross hosts (iCloud's calendar home
is on a per-account partition host).

**Online-meeting links are provider-matched (`booking_handler.go`).** A
`google_meet`/`teams` event type auto-mints a link **only when the primary host's
Expand Down Expand Up @@ -542,6 +567,15 @@ Saving a sub-calendar destination also moves the account-level destination to th
or the choice silently does nothing when the picked calendar lives in a different account
from the current destination.

**Where a calendar id says where credentials go, a saved selection is checked first.** A
provider implementing `calendar.SelectionValidator` (CalDAV, whose id is a collection URL that
free/busy and bookings authenticate to) is asked to accept every id before
`SetAccountCalendars` opens its transaction. A refused id is `*UnknownCalendarError` (400 from
`PUT .../calendars`) and nothing is saved; a failure to check is `ErrCalendarList` (502) or a
reauth error (409), as on the GET. Google and Microsoft do not implement it: their ids are
names inside an API the account's token already scopes, so saving there stays a local write
with no provider call, and aliases such as Google's `"primary"` still save.

**`booking_hosts.external_calendar_id` records where each event actually went** (migration
00055). Reschedule and cancel use it rather than re-resolving the current destination -
otherwise changing the destination orphans every existing booking: the provider 404s, the
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/routes/calendar/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,11 @@
<p class="text-xs text-muted-foreground">No calendars found for this account.</p>
{:else}
<p class="text-xs text-muted-foreground">
Tick the calendars to check for conflicts, and choose the one bookings are
written into.
<span class="font-medium">Check</span> counts busy time on a calendar, so those
times can't be booked. <span class="font-medium">Book</span> is the single calendar
new bookings are written into (one across all your accounts), so it can't be
unticked, only moved to another calendar. Read-only calendars can be checked but
not booked into.
</p>
<div class="space-y-1.5">
<div class="flex items-center gap-2 pb-1 text-xs font-medium text-muted-foreground">
Expand Down
21 changes: 13 additions & 8 deletions internal/caldav/caldav.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func (c *Client) loadConn(ctx context.Context, userID string, checkConflicts, is

// For CalDAV the "calendar id" IS the collection URL, so picking a different calendar
// inside the account is just a different URL to PUT into. Only applies to the write
// target; conflict checking already reads every selected collection.
// target; conflict checking reads every selected collection (conflictConns).
if isDestination == 1 {
if subCal, ok, sErr := connstore.DestinationCalendarID(ctx, c.db, userID, "caldav", cn.username); sErr != nil {
return conn{}, false, sErr
Expand All @@ -182,9 +182,10 @@ func (c *Client) loadConn(ctx context.Context, userID string, checkConflicts, is
return cn, true, nil
}

// conflictConns returns every CalDAV connection for the user with check_conflicts = 1, so a
// user can connect several CalDAV accounts and have them all checked. Decrypt failures on one
// row are logged and skipped (fail-open).
// conflictConns returns one entry per calendar to read for conflicts, across every CalDAV
// connection the user has with check_conflicts = 1, so a user can connect several CalDAV
// accounts and tick several calendars in each. Entries of one account share its credentials and
// differ only in calURL. Decrypt failures on one row are logged and skipped (fail-open).
func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT id, COALESCE(account_email,''), access_token_enc, calendar_id
Expand All @@ -208,9 +209,11 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
return nil, err
}
// Resolve selection + decrypt after the cursor is closed (single-connection DB pool; the
// ConflictCalendarIDs query would deadlock against an open cursor). CalDAV binds one
// calendar per connection, so the sub-calendar picker is simply an on/off toggle: an empty
// result means the user deselected it.
// ConflictCalendarIDs query would deadlock against an open cursor). An account with a saved
// selection yields exactly the calendars ticked in it, and none when all were unticked. One
// that never saved a selection yields its bound collection, which is all any CalDAV
// connection read before its calendars could be listed. The ids are collection URLs, and
// SetAccountCalendars only saves ones this provider listed for the account.
var conns []conn
for _, d := range data {
calIDs, err := calendar.ConflictCalendarIDs(ctx, c.db, "caldav", userID, d.username, d.calURL)
Expand All @@ -225,7 +228,9 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
c.logger.Warn("caldav: skipping connection with bad credentials", "user_id", userID, "error", err)
continue
}
conns = append(conns, conn{id: d.id, username: d.username, password: string(pw), calURL: d.calURL})
for _, calURL := range calIDs {
conns = append(conns, conn{id: d.id, username: d.username, password: string(pw), calURL: calURL})
}
}
return conns, nil
}
Expand Down
152 changes: 140 additions & 12 deletions internal/caldav/calendars.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,155 @@ package caldav
import (
"context"
"database/sql"
"fmt"

"github.com/calnode/calnode/internal/calendar"
)

// ListCalendars returns the connected CalDAV calendar for the account. Discovery currently
// binds one calendar per connection; enumerating multiple CalDAV collections is a future
// enhancement, so this reports the single bound calendar.
// ListCalendars lists every calendar in the account that can hold events: each VEVENT-capable
// collection under the account's calendar home, on the same origin as that home (see
// listCollections). The collection the connection is bound to is marked Primary; Writable comes
// from the server's DAV:current-user-privilege-set, and counts as true when the server does not
// report one.
//
// The server URL typed at connect time is not stored, so discovery restarts from the bound
// collection (see homeForCalendar) using the stored credentials. Nothing new is persisted, which
// is why this needs no migration and works for connections made before calendars were listed.
//
// Every request made here stays on the bound collection's origin, and so does every calendar
// offered: this runs on each picker load and each saved selection, with the account's
// credentials, against URLs the server chose. homeForCalendar will not follow a principal or
// home on another origin, propfind refuses a redirect off it, and a collection off it is dropped
// below even if a listing somehow reached one.
func (c *Client) ListCalendars(ctx context.Context, userID, accountEmail string) ([]calendar.CalendarInfo, error) {
var calID string
cn, ok, err := c.accountConn(ctx, userID, accountEmail)
if err != nil || !ok {
return nil, err
}
home, err := c.homeForCalendar(ctx, cn)
if err != nil {
return nil, err
}
l, err := c.listCollections(ctx, cn.calURL, home, cn.username, cn.password)
if err != nil {
return nil, err
}

out := make([]calendar.CalendarInfo, 0, len(l.collections)+1)
boundListed := false
for _, col := range l.collections {
if !sameOrigin(col.url, cn.calURL) {
c.logger.Warn("caldav: skipping a calendar on another origin from the connected calendar", "connected", cn.calURL, "calendar", col.url)
continue
}
primary := col.url == cn.calURL
boundListed = boundListed || primary
out = append(out, calendar.CalendarInfo{ID: col.url, Name: col.name(), Primary: primary, Writable: col.writable})
}
if !boundListed {
// The bound collection is what free/busy reads until a selection is saved, and it is
// already the URL this connection sends its credentials to, so it stays selectable even
// when the home listing leaves it out (a home found by the parent fallback, say).
// Nothing is known about it beyond what connect found, which is what this method
// reported before calendars were listed.
bound := calendar.CalendarInfo{ID: cn.calURL, Name: lastSegment(cn.calURL), Primary: true, Writable: true}
out = append([]calendar.CalendarInfo{bound}, out...)
}
return out, nil
}

var _ calendar.SelectionValidator = (*Client)(nil)

// ValidateSelection accepts a selection only when every calendar id is one ListCalendars offers
// for the account right now. A CalDAV calendar id is the collection URL that free/busy and
// booking writes send the account's credentials to, so an id the client made up would bypass
// both the connect-time URL check and the same-origin rule applied when listing.
func (c *Client) ValidateSelection(ctx context.Context, userID, accountEmail string, calendarIDs []string) error {
cals, err := c.ListCalendars(ctx, userID, accountEmail)
if err != nil {
return err
}
listed := make(map[string]bool, len(cals))
for _, cal := range cals {
listed[cal.ID] = true
}
for _, id := range calendarIDs {
if !listed[id] {
return &calendar.UnknownCalendarError{CalendarID: id}
}
}
return nil
}

// homeForCalendar finds the calendar home holding the connection's bound collection.
// DAV:current-user-principal is defined on every resource (RFC 5397), and the principal names
// its calendar-home-set, so this is the same walk connect makes, started from the collection
// instead of the server URL. A server that answers neither gets the collection's parent, which is
// the calendar home on the usual layout (.../calendars/<user>/<calendar>/).
//
// A failed principal or home lookup falls through to the parent rather than failing, so a bound
// collection that has since been deleted does not stop the user choosing another one. An
// authentication failure still surfaces: the parent listing is made with the same credentials.
//
// A principal or home href on another origin from the bound collection is not followed, and
// neither is a redirect off it (propfind is pinned to the bound collection). Following either
// would send the account's credentials there on every picker load, and a home there would make
// that origin's collections look local to it. Both fall through to the parent, which is on the
// bound collection's origin by construction.
func (c *Client) homeForCalendar(ctx context.Context, cn conn) (string, error) {
parent := parentCollection(cn.calURL)
ms, reqURL, err := c.propfind(ctx, cn.calURL, cn.calURL, cn.username, cn.password, "0", propCurrentUserPrincipal)
if err != nil {
c.logger.Warn("caldav: principal lookup failed, listing the bound calendar's parent", "calendar", cn.calURL, "error", err)
return parent, nil
}
var principal string
for _, r := range ms.Responses {
if h := r.okProp().CurrentUserPrincipal.Href; h != "" {
principal = resolveRef(reqURL, h)
break
}
}
if principal == "" {
return parent, nil
}
if !sameOrigin(principal, cn.calURL) {
c.logger.Warn("caldav: not following a principal on another origin, listing the bound calendar's parent", "calendar", cn.calURL, "principal", principal)
return parent, nil
}
home, err := c.calendarHome(ctx, cn.calURL, principal, cn.username, cn.password)
switch {
case err != nil:
c.logger.Warn("caldav: calendar home lookup failed, listing the bound calendar's parent", "principal", principal, "error", err)
case home == "":
case !sameOrigin(home, cn.calURL):
c.logger.Warn("caldav: not following a calendar home on another origin, listing the bound calendar's parent", "calendar", cn.calURL, "home", home)
default:
return home, nil
}
return parent, nil
}

// accountConn loads one account's connection (by account email) regardless of its
// check_conflicts / is_destination flags. ok=false when the user has no such account.
func (c *Client) accountConn(ctx context.Context, userID, accountEmail string) (conn, bool, error) {
var cn conn
var pwEnc string
err := c.db.QueryRowContext(ctx, `
SELECT calendar_id FROM calendar_connections
SELECT id, COALESCE(account_email,''), access_token_enc, calendar_id
FROM calendar_connections
WHERE user_id = ? AND provider = 'caldav' AND COALESCE(account_email,'') = ?
LIMIT 1`, userID, accountEmail).Scan(&calID)
LIMIT 1`, userID, accountEmail).Scan(&cn.id, &cn.username, &pwEnc, &cn.calURL)
if err == sql.ErrNoRows {
return nil, nil
return conn{}, false, nil
}
if err != nil {
return nil, err
return conn{}, false, fmt.Errorf("caldav: load account connection: %w", err)
}
pw, err := c.decrypt(pwEnc)
if err != nil {
return conn{}, false, fmt.Errorf("caldav: decrypt password: %w", err)
}
// CalDAV exposes exactly the one collection the user configured, and they configured it
// precisely so Calnode could write there - so unlike Google/Microsoft there is no
// read-only case to detect here.
return []calendar.CalendarInfo{{ID: calID, Name: "Calendar", Primary: true, Writable: true}}, nil
cn.password = string(pw)
return cn, true, nil
}
Loading
Loading