package services import ( "context" "crypto/rand" "encoding/hex" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "slices" "strings" "time" ) // ErrCalendarNameTaken is returned by MakeCalendar when the server // rejects MKCALENDAR with 405 — name already in use. var ErrCalendarNameTaken = errors.New("calendar name already taken on server") // ErrMKCalendarUnsupported is returned by MakeCalendar when the server // outright rejects MKCALENDAR (403/501) — should never fire after a // successful probe, but kept as a defence so we don't loop. var ErrMKCalendarUnsupported = errors.New("server does not support MKCALENDAR") // Tiny CalDAV HTTP client — only the verbs Paliad needs: // - PUT (create / replace event) // - GET (fetch event by path) // - DELETE (remove event) // - PROPFIND with depth=1 (list calendar contents + ETags) // - PROPFIND on the calendar URL (used by the "test connection" button) // // We don't use REPORT because PROPFIND-depth-1 returns enough for our needs // (href + getetag + calendar-data via a follow-up GET) and is supported // by every server Paliad has been tested against (Nextcloud, Radicale, // Baikal, mailcow SOGo). type calDAVClient struct { baseURL string // server root or calendar URL username string password string hc *http.Client } func newCalDAVClient(baseURL, username, password string) *calDAVClient { return &calDAVClient{ baseURL: strings.TrimRight(baseURL, "/"), username: username, password: password, hc: &http.Client{ Timeout: 30 * time.Second, }, } } func (c *calDAVClient) do(ctx context.Context, method, path, body, contentType string) (*http.Response, error) { u := c.absURL(path) req, err := http.NewRequestWithContext(ctx, method, u, strings.NewReader(body)) if err != nil { return nil, err } req.SetBasicAuth(c.username, c.password) if contentType != "" { req.Header.Set("Content-Type", contentType) } return c.hc.Do(req) } // absURL resolves path against baseURL. If path is already absolute (starts // with http:// or https://), it's used as-is. func (c *calDAVClient) absURL(path string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } if path == "" { return c.baseURL } if strings.HasPrefix(path, "/") { // Same scheme + host as base. base, err := url.Parse(c.baseURL) if err == nil && base.Scheme != "" && base.Host != "" { return base.Scheme + "://" + base.Host + path } } return c.baseURL + "/" + strings.TrimLeft(path, "/") } // PutEvent uploads body to /.ics. Returns the new ETag. func (c *calDAVClient) PutEvent(ctx context.Context, calendarPath, uid, body string) (string, error) { path := joinPath(calendarPath, uid+".ics") resp, err := c.do(ctx, "PUT", path, body, "text/calendar; charset=utf-8") if err != nil { return "", fmt.Errorf("PUT: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { raw, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("PUT %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw)) } etag := resp.Header.Get("ETag") if etag == "" { // Some servers don't return ETag on PUT — re-PROPFIND to fetch it. entries, ferr := c.PropfindCalendar(ctx, calendarPath) if ferr == nil { for _, e := range entries { if strings.HasSuffix(e.Href, uid+".ics") { etag = e.ETag break } } } } return etag, nil } // DeleteEvent removes a previously-PUT event. 404 is treated as success. func (c *calDAVClient) DeleteEvent(ctx context.Context, calendarPath, uid string) error { path := joinPath(calendarPath, uid+".ics") resp, err := c.do(ctx, "DELETE", path, "", "") if err != nil { return fmt.Errorf("DELETE: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { return nil } if resp.StatusCode >= 300 { return fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, resp.Status) } return nil } // GetEvent fetches the raw iCalendar body of one event. func (c *calDAVClient) GetEvent(ctx context.Context, href string) (string, error) { resp, err := c.do(ctx, "GET", href, "", "") if err != nil { return "", fmt.Errorf("GET: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("GET %s: %d", href, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("read body: %w", err) } return string(body), nil } // CalDAVEntry is one row from a depth-1 PROPFIND on a calendar. type CalDAVEntry struct { Href string ETag string } // PropfindCalendar lists all event hrefs + ETags inside calendarPath. func (c *calDAVClient) PropfindCalendar(ctx context.Context, calendarPath string) ([]CalDAVEntry, error) { body := ` ` req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(calendarPath), strings.NewReader(body)) if err != nil { return nil, err } req.SetBasicAuth(c.username, c.password) req.Header.Set("Depth", "1") req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("PROPFIND: %w", err) } defer resp.Body.Close() if resp.StatusCode != 207 { raw, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw)) } return parseMultiStatus(resp.Body) } // multigetMaxHrefs caps the number of hrefs in one REPORT request to keep // us well within Google's documented limit (~200) and iCloud's // rate-shaping. Callers chunk larger lists into multiple requests. const multigetMaxHrefs = 100 // MultigetEvent is one (href, etag, calendar-data) result returned by // ReportMultiget. CalendarData is the raw iCalendar body and is fed // straight into parseICalendar; ETag matches the value that would have // been returned by PROPFIND for the same href. type MultigetEvent struct { Href string ETag string CalendarData string } // ReportMultiget runs a `REPORT calendar-multiget` (RFC 4791 §7.9) // against calendarPath and returns one MultigetEvent per requested href. // Hrefs missing from the response (404 inside the multistatus) are // omitted from the returned slice — callers should treat that as a // remote deletion. Hrefs are auto-chunked at multigetMaxHrefs. func (c *calDAVClient) ReportMultiget(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) { if len(hrefs) == 0 { return nil, nil } out := []MultigetEvent{} for start := 0; start < len(hrefs); start += multigetMaxHrefs { end := min(start+multigetMaxHrefs, len(hrefs)) chunk, err := c.reportMultigetChunk(ctx, calendarPath, hrefs[start:end]) if err != nil { return nil, err } out = append(out, chunk...) } return out, nil } func (c *calDAVClient) reportMultigetChunk(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) { var b strings.Builder b.WriteString(` `) for _, h := range hrefs { b.WriteString(" ") _ = xml.EscapeText(&b, []byte(h)) b.WriteString("\n") } b.WriteString(``) req, err := http.NewRequestWithContext(ctx, "REPORT", c.absURL(calendarPath), strings.NewReader(b.String())) if err != nil { return nil, err } req.SetBasicAuth(c.username, c.password) req.Header.Set("Depth", "1") req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("REPORT: %w", err) } defer resp.Body.Close() if resp.StatusCode != 207 { raw, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("REPORT %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw)) } return parseMultigetResponse(resp.Body) } // PropfindRoot performs a Depth:0 PROPFIND on the calendar URL — used by // the "Test connection" button to verify auth + URL without storing creds. func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error { body := ` ` req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(path), strings.NewReader(body)) if err != nil { return err } req.SetBasicAuth(c.username, c.password) req.Header.Set("Depth", "0") req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := c.hc.Do(req) if err != nil { return fmt.Errorf("PROPFIND: %w", err) } defer resp.Body.Close() if resp.StatusCode != 207 && resp.StatusCode != 200 { raw, _ := io.ReadAll(resp.Body) return fmt.Errorf("PROPFIND %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw)) } return nil } // DiscoveredCalendar is one calendar collection enumerated by // DiscoverCalendars. supportedComponents lists the iCal component types // the server advertises (VEVENT, VTODO, …); the picker filters to ones // supporting VEVENT. type DiscoveredCalendar struct { Href string DisplayName string SupportedComponents []string } // DiscoverCalendars walks the CalDAV discovery chain (RFC 6764 §6 / // RFC 6638 §10): server root → current-user-principal → calendar-home-set // → enumeration of child calendar collections. // // Returns the discovered calendars + the calendar-home-set URL so the // caller can issue MKCALENDAR against it in Slice 2c. Hrefs are // returned as-is (absolute or path-rooted) per server response; the // client's absURL handles both at PUT time. func (c *calDAVClient) DiscoverCalendars(ctx context.Context, serverURL string) ([]DiscoveredCalendar, string, error) { principal, err := c.findCurrentUserPrincipal(ctx, serverURL) if err != nil { return nil, "", fmt.Errorf("current-user-principal: %w", err) } home, err := c.findCalendarHomeSet(ctx, principal) if err != nil { return nil, "", fmt.Errorf("calendar-home-set: %w", err) } calendars, err := c.listCalendars(ctx, home) if err != nil { return nil, home, fmt.Errorf("list calendars: %w", err) } return calendars, home, nil } func (c *calDAVClient) findCurrentUserPrincipal(ctx context.Context, urlPath string) (string, error) { body := ` ` hrefs, err := c.propfindHrefs(ctx, urlPath, "0", body, "current-user-principal") if err != nil { return "", err } if len(hrefs) == 0 { return "", fmt.Errorf("server returned no current-user-principal") } return hrefs[0], nil } func (c *calDAVClient) findCalendarHomeSet(ctx context.Context, principalPath string) (string, error) { body := ` ` hrefs, err := c.propfindHrefs(ctx, principalPath, "0", body, "calendar-home-set") if err != nil { return "", err } if len(hrefs) == 0 { return "", fmt.Errorf("server returned no calendar-home-set") } return hrefs[0], nil } func (c *calDAVClient) listCalendars(ctx context.Context, homePath string) ([]DiscoveredCalendar, error) { body := ` ` req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(homePath), strings.NewReader(body)) if err != nil { return nil, err } req.SetBasicAuth(c.username, c.password) req.Header.Set("Depth", "1") req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("PROPFIND: %w", err) } defer resp.Body.Close() if resp.StatusCode != 207 { raw, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", homePath, resp.StatusCode, resp.Status, string(raw)) } var ms calendarHomeMultiStatus if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { return nil, fmt.Errorf("decode home-set multistatus: %w", err) } out := []DiscoveredCalendar{} for _, r := range ms.Responses { var displayname string isCalendar := false comps := []string{} for _, ps := range r.Propstat { if !strings.Contains(ps.Status, "200") { continue } if ps.Prop.ResourceType.Calendar != nil { isCalendar = true } if ps.Prop.DisplayName != "" { displayname = ps.Prop.DisplayName } for _, comp := range ps.Prop.SupportedCalendarComponentSet.Comp { if comp.Name != "" { comps = append(comps, comp.Name) } } } if !isCalendar { continue } // Filter to calendars that advertise VEVENT support — task / address // books slip into the home-set on Apple iCloud and we don't want // those in the picker. if len(comps) > 0 && !slices.Contains(comps, "VEVENT") { continue } out = append(out, DiscoveredCalendar{ Href: r.Href, DisplayName: displayname, SupportedComponents: comps, }) } return out, nil } // propfindHrefs runs a PROPFIND and returns the hrefs nested under the // named property's value. Used for current-user-principal + // calendar-home-set extraction where the property body is a single href. func (c *calDAVClient) propfindHrefs(ctx context.Context, urlPath, depth, body, propName string) ([]string, error) { req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(urlPath), strings.NewReader(body)) if err != nil { return nil, err } req.SetBasicAuth(c.username, c.password) req.Header.Set("Depth", depth) req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("PROPFIND: %w", err) } defer resp.Body.Close() if resp.StatusCode != 207 && resp.StatusCode != 200 { raw, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", urlPath, resp.StatusCode, resp.Status, string(raw)) } var ms propHrefMultiStatus if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { return nil, fmt.Errorf("decode multistatus for %s: %w", propName, err) } out := []string{} for _, r := range ms.Responses { for _, ps := range r.Propstat { if !strings.Contains(ps.Status, "200") { continue } for _, h := range ps.Prop.CurrentUserPrincipal.Hrefs { out = append(out, h) } for _, h := range ps.Prop.CalendarHomeSet.Hrefs { out = append(out, h) } } } return out, nil } // --- MKCALENDAR capability probe + provisioning (Slice 2c) --- // ProbeMKCalendar reports whether the CalDAV server accepts MKCALENDAR // against the calendar-home-set. Two-step per design §4.2: // // 1. OPTIONS on the home URL — if the server returns `Allow:` listing // MKCALENDAR, we're done. // 2. Synthetic probe — issue MKCALENDAR against a random // `.paliad-probe-/` path and DELETE it. Catches legacy SOGo // and misconfigured Radicales that don't list MKCALENDAR in Allow // but still accept it. Servers that 405/501 the synthetic probe // are recorded as no-MKCALENDAR; further attempts skip the probe. // // The probe never persists state — that's the service-layer's job via // CalDAVService.MakeCalendar. func (c *calDAVClient) ProbeMKCalendar(ctx context.Context, homePath string) (bool, error) { if allows, err := c.optionsAllows(ctx, homePath); err == nil { if slices.Contains(allows, "MKCALENDAR") { return true, nil } // OPTIONS responded but doesn't list MKCALENDAR — fall through to // synthetic probe; some servers omit MKCALENDAR from Allow even // when they accept it. OPTIONS-returns-no-MKCALENDAR is not a // hard negative. } // Synthetic probe — a single MKCALENDAR against a randomised name // that the server is overwhelmingly unlikely to already have. probePath := joinPath(homePath, ".paliad-probe-"+randomToken(6)+"/") mkBody := ` paliad-probe ` req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(probePath), strings.NewReader(mkBody)) if err != nil { return false, err } req.SetBasicAuth(c.username, c.password) req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := c.hc.Do(req) if err != nil { return false, fmt.Errorf("MKCALENDAR probe: %w", err) } resp.Body.Close() switch resp.StatusCode { case http.StatusCreated, http.StatusOK: // Server accepted the probe. Tear down the probe collection so // we don't leak a junk calendar; if the DELETE fails we shrug // (best effort — the user's calendar list will have one // .paliad-probe-* entry; not the end of the world). _ = c.deleteCollection(ctx, probePath) return true, nil case http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusForbidden: return false, nil default: // Unknown — treat as no-MKCALENDAR to be safe; the user can // still bind by URL. return false, nil } } // MakeCalendar issues MKCALENDAR against home// and // returns the absolute path that was created. The caller is // responsible for picking a free slug; 405 from the server means // "name already taken — pick another". func (c *calDAVClient) MakeCalendar(ctx context.Context, homePath, calendarName, displayName string) (string, error) { path := joinPath(homePath, calendarName+"/") body := mkcalendarBody(displayName) req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(path), strings.NewReader(body)) if err != nil { return "", err } req.SetBasicAuth(c.username, c.password) req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := c.hc.Do(req) if err != nil { return "", fmt.Errorf("MKCALENDAR: %w", err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusCreated, http.StatusOK: return path, nil case http.StatusMethodNotAllowed: return "", ErrCalendarNameTaken case http.StatusForbidden, http.StatusNotImplemented: return "", ErrMKCalendarUnsupported default: raw, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("MKCALENDAR %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw)) } } func mkcalendarBody(displayName string) string { var b strings.Builder b.WriteString(` `) _ = xml.EscapeText(&b, []byte(displayName)) b.WriteString(` `) return b.String() } // optionsAllows returns the methods listed in the Allow header of an // OPTIONS response. Caseless match per RFC 7231 §7.4.1. func (c *calDAVClient) optionsAllows(ctx context.Context, path string) ([]string, error) { req, err := http.NewRequestWithContext(ctx, "OPTIONS", c.absURL(path), nil) if err != nil { return nil, err } req.SetBasicAuth(c.username, c.password) resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("OPTIONS: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("OPTIONS %s: %d", path, resp.StatusCode) } out := []string{} for _, h := range resp.Header.Values("Allow") { for _, m := range strings.Split(h, ",") { out = append(out, strings.ToUpper(strings.TrimSpace(m))) } } return out, nil } // deleteCollection sends a DELETE that doesn't care about 404. func (c *calDAVClient) deleteCollection(ctx context.Context, path string) error { req, err := http.NewRequestWithContext(ctx, "DELETE", c.absURL(path), nil) if err != nil { return err } req.SetBasicAuth(c.username, c.password) resp, err := c.hc.Do(req) if err != nil { return err } resp.Body.Close() return nil } // randomToken returns a short hex string of `n` bytes. Used for the // synthetic MKCALENDAR probe path; doesn't need to be cryptographically // strong (the worst-case is a collision with an existing calendar of // the same name, which we catch as ErrCalendarNameTaken upstream). func randomToken(n int) string { buf := make([]byte, n) _, _ = rand.Read(buf) return hex.EncodeToString(buf) } // joinPath cleans up double slashes between calendar path and uid. func joinPath(base, name string) string { base = strings.TrimRight(base, "/") name = strings.TrimLeft(name, "/") if base == "" { return "/" + name } return base + "/" + name } // --- Multistatus parsing --- type msResponse struct { XMLName xml.Name `xml:"DAV: response"` Href string `xml:"DAV: href"` Propstat []propStat `xml:"DAV: propstat"` } type propStat struct { XMLName xml.Name `xml:"DAV: propstat"` Status string `xml:"DAV: status"` Prop struct { ETag string `xml:"DAV: getetag"` CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"` ResourceType struct { Collection *struct{} `xml:"DAV: collection"` } `xml:"DAV: resourcetype"` } `xml:"DAV: prop"` } type multiStatus struct { XMLName xml.Name `xml:"DAV: multistatus"` Responses []msResponse `xml:"DAV: response"` } // propHrefMultiStatus is used to extract children out of the // and properties. // Both render as: . type propHrefMultiStatus struct { XMLName xml.Name `xml:"DAV: multistatus"` Responses []propHrefResponse `xml:"DAV: response"` } type propHrefResponse struct { XMLName xml.Name `xml:"DAV: response"` Href string `xml:"DAV: href"` Propstat []propHrefPropstat `xml:"DAV: propstat"` } type propHrefPropstat struct { XMLName xml.Name `xml:"DAV: propstat"` Status string `xml:"DAV: status"` Prop struct { CurrentUserPrincipal struct { Hrefs []string `xml:"DAV: href"` } `xml:"DAV: current-user-principal"` CalendarHomeSet struct { Hrefs []string `xml:"DAV: href"` } `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set"` } `xml:"DAV: prop"` } // calendarHomeMultiStatus parses the response to a Depth:1 PROPFIND on // calendar-home-set asking for resourcetype + displayname + // supported-calendar-component-set. type calendarHomeMultiStatus struct { XMLName xml.Name `xml:"DAV: multistatus"` Responses []calendarHomeResponse `xml:"DAV: response"` } type calendarHomeResponse struct { XMLName xml.Name `xml:"DAV: response"` Href string `xml:"DAV: href"` Propstat []calendarHomePropstat `xml:"DAV: propstat"` } type calendarHomePropstat struct { XMLName xml.Name `xml:"DAV: propstat"` Status string `xml:"DAV: status"` Prop struct { DisplayName string `xml:"DAV: displayname"` ResourceType struct { Calendar *struct{} `xml:"urn:ietf:params:xml:ns:caldav calendar"` } `xml:"DAV: resourcetype"` SupportedCalendarComponentSet struct { Comp []struct { Name string `xml:"name,attr"` } `xml:"urn:ietf:params:xml:ns:caldav comp"` } `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set"` } `xml:"DAV: prop"` } func parseMultigetResponse(r io.Reader) ([]MultigetEvent, error) { var ms multiStatus dec := xml.NewDecoder(r) if err := dec.Decode(&ms); err != nil { return nil, fmt.Errorf("decode multistatus: %w", err) } out := []MultigetEvent{} for _, resp := range ms.Responses { var etag, data string ok := false for _, ps := range resp.Propstat { if !strings.Contains(ps.Status, "200") { continue } etag = strings.Trim(ps.Prop.ETag, `"`) data = ps.Prop.CalendarData if data != "" { ok = true } } if !ok { // 404 / 403 on this specific href — treat as missing, skip. continue } out = append(out, MultigetEvent{Href: resp.Href, ETag: etag, CalendarData: data}) } return out, nil } func parseMultiStatus(r io.Reader) ([]CalDAVEntry, error) { var ms multiStatus dec := xml.NewDecoder(r) if err := dec.Decode(&ms); err != nil { return nil, fmt.Errorf("decode multistatus: %w", err) } out := []CalDAVEntry{} for _, resp := range ms.Responses { var etag string isCollection := false for _, ps := range resp.Propstat { if !strings.Contains(ps.Status, "200") { continue } etag = strings.Trim(ps.Prop.ETag, `"`) if ps.Prop.ResourceType.Collection != nil { isCollection = true } } if isCollection { continue // skip the calendar itself } if !strings.HasSuffix(strings.ToLower(resp.Href), ".ics") { continue } out = append(out, CalDAVEntry{Href: resp.Href, ETag: etag}) } return out, nil }