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.
767 lines
25 KiB
Go
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
|
|
}
|