Skip to content
Merged
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad

## [Unreleased]

### Security
- **Moving or cancelling a CalDAV booking no longer sends another account's app password
to the server holding the event.** A host can connect several CalDAV accounts, and
rescheduling or cancelling authenticated as whichever account was the destination at
the time, not the one the event was written to. After a host moved their destination
from an account on one server to an account on another, every reschedule or cancel of
an older booking sent the new account's username and app password to the old account's
server. A server that refused them left the calendar unchanged and the reconciler
retried, sending them again every sweep for a cancelled booking and until the end time
for a moved one. A server that answered 404 instead was taken at its word: the event
counted as already gone and stayed where it was.

Update and cancel now authenticate as the account that holds the event, found from what
the booking stored: the calendar recorded at creation, or failing that the connected
calendar whose URL contains the event's URL (same scheme, host and port). If no single
account can be established, nothing is sent, and the reconciler logs one warning and stops
retrying that event rather than refusing it again every sweep; the event stays where it
is. Hosts who moved a
CalDAV destination between accounts on different servers should consider rotating the
app password of the account they moved to.

### Fixed
- **The Zoom setup text no longer promises that an unpublished app works for "your own
team".** Zoom only lets users inside the Zoom account that owns an unpublished app
Expand All @@ -20,6 +41,12 @@ 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 event is moved or deleted even after the destination moves to Google or
Microsoft.** The event was handed to the new destination's provider, which could not
find an id it never issued, so the event stayed on the CalDAV calendar at its old time,
or after its booking was cancelled. A CalDAV event id is the event's URL, which is now
enough to route it back to the CalDAV provider whatever the destination is.

## [0.9.0] - 2026-09-10

### Added
Expand Down
21 changes: 19 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,25 @@ from the current destination.
otherwise changing the destination orphans every existing booking: the provider 404s, the
booking cancels in Calnode, and the meeting stays on the host's calendar with nothing
surfaced. Empty means "resolve the old way", correct for bookings that predate the column.
Known limit: this rescues a change of calendar *within* an account, not a move to a
different account, which would need the account recorded too.
Known limit (Google, Microsoft): this rescues a change of calendar *within* an account, not
a move to a different account, which would need the account recorded too.

CalDAV does not have that limit, because its event id is the event's absolute URL and so
already names the server. `Service.UpdateEvent`/`CancelEvent` route an id the CalDAV
provider recognizes (`calendar.EventRecognizer`) to it whatever the destination is now, and
it authenticates as the connected account that holds the event (`caldav.eventConn`), never
as the destination: the account whose bound or saved calendar is the recorded calendar id,
else whose calendar URL contains the event URL (same scheme, host and port, path under it;
most specific wins). No match, or a tie, sends nothing and returns an error. This is a
credential boundary, not a routing nicety: accounts can live on different servers, and the
destination's app password sent to an older event's URL goes to someone else's server.

That refusal matches `calendar.ErrEventUnreachable`, and the reconciler treats it as final:
it logs one warning, clears `needs_sync` (reschedule) or the event id (cancel, logged
redacted since it is the only record), and does not retry. Retrying cannot help, because it
re-reads the same connections and refuses the same way, and the cancellation sweep has no
date bound, so it would repeat forever. Any other error, such as a server that is down or
refuses the stored password, stays retryable.

---

Expand Down
199 changes: 196 additions & 3 deletions internal/caldav/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

Expand Down Expand Up @@ -41,8 +42,12 @@ func (c *Client) CreateEvent(ctx context.Context, userID string, p calendar.Crea
// UpdateEvent moves an existing event to new start/end times. CalDAV has no partial update, so
// it GETs the current object, rewrites DTSTART/DTEND (and bumps SEQUENCE/DTSTAMP), and PUTs it
// back — preserving summary, description, location, and attendees.
//
// It authenticates as the account that holds the event (eventConn), not as the current
// destination, and returns an error without sending anything when that account cannot be
// established.
func (c *Client) UpdateEvent(ctx context.Context, userID, calendarID, eventID string, start, end time.Time) error {
cn, ok, err := c.loadConn(ctx, userID, -1, 1)
cn, ok, err := c.eventConn(ctx, userID, calendarID, eventID)
if err != nil || !ok {
return err
}
Expand All @@ -67,9 +72,10 @@ func (c *Client) UpdateEvent(ctx context.Context, userID, calendarID, eventID st
return nil
}

// CancelEvent deletes the event resource from the destination calendar.
// CancelEvent deletes the event resource from the calendar it was written to, as the account
// that holds it (see UpdateEvent).
func (c *Client) CancelEvent(ctx context.Context, userID, calendarID, eventID string) error {
cn, ok, err := c.loadConn(ctx, userID, -1, 1)
cn, ok, err := c.eventConn(ctx, userID, calendarID, eventID)
if err != nil || !ok {
return err
}
Expand All @@ -83,6 +89,193 @@ func (c *Client) CancelEvent(ctx context.Context, userID, calendarID, eventID st
return nil
}

// RecognizesEvent reports whether eventID is a CalDAV event id: the absolute http(s) URL of
// the event resource, which is what CreateEvent returns. Google and Microsoft ids are opaque
// tokens and never URLs, so calendar.Service uses this to send an update or cancel here even
// after the user's destination has moved to another provider.
func (c *Client) RecognizesEvent(eventID string) bool {
_, ok := parseDAVURL(eventID)
return ok
}

var (
errEventNotURL error = unreachableEventError("caldav: event id is not a CalDAV resource URL; nothing was sent")
errNoEventOwner error = unreachableEventError("caldav: none of the user's CalDAV accounts holds this event (it is not inside any of their calendars); nothing was sent")
errManyEventOwner error = unreachableEventError("caldav: more than one of the user's CalDAV accounts could hold this event; nothing was sent")
)

// unreachableEventError is an update or cancel refused before any request, because the stored
// ids do not pick out one account to send it as. It matches calendar.ErrEventUnreachable, which
// is what tells the reconciler that retrying cannot help.
type unreachableEventError string

func (e unreachableEventError) Error() string { return string(e) }

func (e unreachableEventError) Is(target error) bool { return target == calendar.ErrEventUnreachable }

// eventAccount is one connected CalDAV account and every collection URL it is known to use: the
// calendar bound at connect, then any calendars saved for the account in connection_calendars.
type eventAccount struct {
id, username, pwEnc string
collections []string
}

// eventConn resolves the connection an update or cancel of eventID must authenticate as: the
// account that holds the event, never whichever account is the destination now. A host can
// connect several CalDAV accounts on different servers and move the destination between them,
// and an event's URL names the server it lives on, so using the destination's credentials sends
// that account's password to another account's server.
//
// ok=false with a nil error means the user has no CalDAV connection at all, so there is no
// account to act as (the Provider contract's "no matching connection"). Otherwise it returns an
// error, having sent nothing, unless pickEventOwner finds exactly one account.
func (c *Client) eventConn(ctx context.Context, userID, calendarID, eventID string) (conn, bool, error) {
var accounts []eventAccount
rows, err := c.db.QueryContext(ctx, `
SELECT id, COALESCE(account_email,''), access_token_enc, calendar_id
FROM calendar_connections
WHERE user_id = ? AND provider = 'caldav'`, userID)
if err != nil {
return conn{}, false, fmt.Errorf("caldav: load connections: %w", err)
}
for rows.Next() {
var a eventAccount
var bound string
if err := rows.Scan(&a.id, &a.username, &a.pwEnc, &bound); err != nil {
rows.Close() // #nosec G104 -- already returning the scan error; nothing more actionable
return conn{}, false, fmt.Errorf("caldav: scan connection: %w", err)
}
a.collections = []string{bound}
accounts = append(accounts, a)
}
rows.Close() // #nosec G104 -- rows already fully consumed above; nothing actionable on close error
if err := rows.Err(); err != nil {
return conn{}, false, err
}
if len(accounts) == 0 {
c.logger.Warn("caldav: no CalDAV account is connected to move or cancel an event; it stays on its calendar",
"user_id", userID)
return conn{}, false, nil
}

// A second query, not a join, and only after the cursor above is closed: the pool is a
// single connection.
rows, err = c.db.QueryContext(ctx, `
SELECT account_email, calendar_id FROM connection_calendars
WHERE user_id = ? AND provider = 'caldav'`, userID)
if err != nil {
return conn{}, false, fmt.Errorf("caldav: load saved calendars: %w", err)
}
for rows.Next() {
var email, calID string
if err := rows.Scan(&email, &calID); err != nil {
rows.Close() // #nosec G104 -- already returning the scan error; nothing more actionable
return conn{}, false, fmt.Errorf("caldav: scan saved calendar: %w", err)
}
for i := range accounts {
if accounts[i].username == email {
accounts[i].collections = append(accounts[i].collections, calID)
}
}
}
rows.Close() // #nosec G104 -- rows already fully consumed above; nothing actionable on close error
if err := rows.Err(); err != nil {
return conn{}, false, err
}

i, err := pickEventOwner(accounts, calendarID, eventID)
if err != nil {
return conn{}, false, err
}
a := accounts[i]
pw, err := c.decrypt(a.pwEnc)
if err != nil {
return conn{}, false, fmt.Errorf("caldav: decrypt password: %w", err)
}
return conn{id: a.id, username: a.username, password: string(pw)}, true, nil
}

// pickEventOwner returns the index of the one account that holds eventID.
//
// An account can hold the event only if the event URL lies inside one of its collections: same
// scheme, host and port, and a path under the collection's at a segment boundary. That is what
// guarantees an account's credentials only go to a server that account is already configured to
// use. Among the accounts that pass, one whose collection is the calendar id recorded at creation
// wins; failing that (no id recorded, as for reassign and bookings that predate the column, or a
// calendar no longer saved) the account with the most specific collection does. A tie is refused
// rather than guessed.
func pickEventOwner(accounts []eventAccount, calendarID, eventID string) (int, error) {
ev, ok := parseDAVURL(eventID)
if !ok {
return -1, errEventNotURL
}
cal, calOK := parseDAVURL(calendarID)

best, tie := -1, false
var bestExact bool
bestDepth := -1
for i, a := range accounts {
held, exact, depth := false, false, -1
for _, raw := range a.collections {
col, ok := parseDAVURL(raw)
if !ok || !col.contains(ev) {
continue
}
held = true
exact = exact || (calOK && col.sameCollection(cal))
depth = max(depth, len(strings.TrimRight(col.path, "/")))
}
switch {
case !held:
case best < 0 || (exact && !bestExact) || (exact == bestExact && depth > bestDepth):
best, bestExact, bestDepth, tie = i, exact, depth, false
case exact == bestExact && depth == bestDepth:
tie = true
}
}
switch {
case best < 0:
return -1, errNoEventOwner
case tie:
return -1, errManyEventOwner
}
return best, nil
}

// davURL is the part of a CalDAV URL that decides where a request, and its credentials, go.
type davURL struct {
scheme, host, port, path string
}

// parseDAVURL accepts only an absolute http(s) URL with a host. The port is made explicit and the
// host lower-cased, so equivalent spellings of one server compare equal. The path is kept in its
// escaped form, the form the event URL was built from.
func parseDAVURL(raw string) (davURL, bool) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return davURL{}, false
}
port := u.Port()
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
return davURL{scheme: u.Scheme, host: strings.ToLower(u.Hostname()), port: port, path: u.EscapedPath()}, true
}

func (d davURL) sameOrigin(o davURL) bool {
return d.scheme == o.scheme && d.host == o.host && d.port == o.port
}

// contains reports whether resource lies inside the collection d.
func (d davURL) contains(resource davURL) bool {
dir := strings.TrimRight(d.path, "/") + "/"
return d.sameOrigin(resource) && len(resource.path) > len(dir) && strings.HasPrefix(resource.path, dir)
}

func (d davURL) sameCollection(o davURL) bool {
return d.sameOrigin(o) && strings.TrimRight(d.path, "/") == strings.TrimRight(o.path, "/")
}

// getICS fetches a calendar resource, returning its body, ETag, and HTTP status.
func (c *Client) getICS(ctx context.Context, rawURL, username, password string) (body, etag string, status int, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
Expand Down
Loading
Loading