Files
paliad/internal/services/caldav_client.go
mAi fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00

767 lines
25 KiB
Go

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 <calendarPath>/<uid>.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 := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getetag/>
<d:resourcetype/>
</d:prop>
</d:propfind>`
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(`<?xml version="1.0" encoding="utf-8"?>
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
`)
for _, h := range hrefs {
b.WriteString(" <D:href>")
_ = xml.EscapeText(&b, []byte(h))
b.WriteString("</D:href>\n")
}
b.WriteString(`</C:calendar-multiget>`)
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 := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:resourcetype/>
<d:displayname/>
</d:prop>
</d:propfind>`
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 := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:current-user-principal/></d:prop>
</d:propfind>`
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 := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-home-set/></d:prop>
</d:propfind>`
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 := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype/>
<d:displayname/>
<c:supported-calendar-component-set/>
</d:prop>
</d:propfind>`
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-<short>/` 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 := `<?xml version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:set><D:prop><D:displayname>paliad-probe</D:displayname></D:prop></D:set>
</C:mkcalendar>`
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/<calendarName>/ 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 version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:set>
<D:prop>
<D:displayname>`)
_ = xml.EscapeText(&b, []byte(displayName))
b.WriteString(`</D:displayname>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
</D:prop>
</D:set>
</C:mkcalendar>`)
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 <DAV:href> children out of the
// <D:current-user-principal/> and <C:calendar-home-set/> properties.
// Both render as: <prop><name><href>…</href></name></prop>.
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
}