package caldav import ( "bytes" "context" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "path" "regexp" "strconv" "strings" "time" "git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/icalfix" "git.arnef.de/arnef/nidus/internal/icssub" "git.arnef.de/arnef/nidus/internal/store" ical "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" ) // sharedNameSep separates the owner from the calendar name in the // synthetic name used for calendars shared with another user, e.g. // "alice~work" for alice's "work" calendar as seen by whoever it was // shared with. It must not collide with characters allowed in real // calendar names (validated wherever calendar names are taken as input). const sharedNameSep = "~" // Backend implements caldav.Backend using a filesystem store. type Backend struct { cfg *config.Config store *store.Store dbase *db.DB // may be nil if sharing is not configured logger *slog.Logger icsCache *icssub.Cache } // NewBackend creates a CalDAV backend over an existing ICS cache. // dbase may be nil, in which case calendar sharing is disabled (only a // user's own calendars are visible). The icsCache is shared with any // other consumers (notably the web UI) so CalDAV and the web calendar // page see identical, cache-consistent events for ICS subscriptions. func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) *Backend { if icsCache == nil { icsCache = icssub.NewCache(icssub.DefaultTTL) } return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icsCache} } // NewHandler returns an http.Handler for the /cal/ prefix. // // It wraps the go-webdav caldav.Handler with a small response-rewriting // middleware that injects the non-standard (Apple/DAVx5) calendar-color // property into PROPFIND responses for calendar collections, since // go-webdav's caldav.Backend interface has no extension point for // vendor-specific WebDAV properties. func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) http.Handler { b := NewBackend(cfg, st, dbase, logger, icsCache) return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}} } // -------- caldav.Backend interface -------- func (b *Backend) CurrentUserPrincipal(ctx context.Context) (string, error) { if auth.FromContext(ctx) == nil { return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } // Must resolve to a path served by this same handler (i.e. under /cal/) // at exactly one path segment of depth, since go-webdav's caldav server // classifies resources purely by path depth relative to Handler.Prefix: // depth 1 = principal, depth 2 = home-set, depth 3 = calendar, depth 4 = // calendar object. A /principals// path would never be reached // (nothing is mounted there) and would break discovery. return calPrincipalPath(), nil } func (b *Backend) CalendarHomeSetPath(ctx context.Context) (string, error) { if auth.FromContext(ctx) == nil { return "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } return calHomePath(), nil } func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { p := auth.FromContext(ctx) if p == nil { return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } // The synthetic Birthdays calendar is always present, computed from // the requester's own contacts. names, err := b.dbase.ListCalendars(p.Username) if err != nil { return nil, fmt.Errorf("listing calendars: %w", err) } cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)} for _, cal := range names { if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil { b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err) continue } cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name)) } // Also include the requester's own read-only ICS/webcal subscriptions. subs, err := b.dbase.ListICSSubscriptions(p.Username) if err != nil { b.logger.Warn("listing ics subscriptions", "error", err) } for _, sub := range subs { cals = append(cals, b.icsSubscriptionCalendarMeta(p.Username, sub)) } // Also include any extra calendars that exist on disk but aren't registered disk, _ := b.store.ListCollections(p.Username) configured := make(map[string]bool) for _, cal := range names { configured["cal-"+cal.Name] = true } for _, dir := range disk { if strings.HasPrefix(dir, "cal-") && !configured[dir] { name := strings.TrimPrefix(dir, "cal-") cals = append(cals, b.calendarMeta(p.Username, name, name)) } } // Include calendars other users have shared with this one. if b.dbase != nil { shares, err := b.dbase.CalendarsSharedWith(p.Username) if err != nil { b.logger.Warn("listing shared calendars", "error", err) } for _, sh := range shares { if _, err := b.store.GetCollection(sh.Owner, "cal-"+sh.CalendarName); err != nil { continue // owner's calendar no longer exists } localName := sharedCalendarName(sh.Owner, sh.CalendarName) displayName := b.sharedDisplayName(p.Username, sh.Owner, sh.CalendarName) cals = append(cals, b.calendarMetaNamed(sh.Owner, sh.CalendarName, localName, displayName)) } } return cals, nil } func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Calendar, error) { requester, localName, err := b.parseCalPath(ctx, calPath) if err != nil { return nil, err } if localName == birthdaysCalendarName { cal := b.birthdaysCalendarMeta(requester) return &cal, nil } if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil { cal := b.icsSubscriptionCalendarMeta(requester, sub) return &cal, nil } owner, realName, _, err := b.resolveCalendar(requester, localName, false) if err != nil { return nil, err } if _, err := b.store.GetCollection(owner, "cal-"+realName); err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } displayName := realName if owner != requester { displayName = b.sharedDisplayName(requester, owner, realName) } cal := b.calendarMetaNamed(owner, realName, localName, displayName) return &cal, nil } func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { requester, localName, objID, err := b.parseObjPath(ctx, objPath) if err != nil { return nil, err } if localName == birthdaysCalendarName { return b.birthdayCalendarObject(requester, objPath, objID) } if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil { return b.icsSubscriptionCalendarObject(localName, objID, sub) } owner, realName, _, err := b.resolveCalendar(requester, localName, false) if err != nil { return nil, err } data, err := b.store.GetObject(owner, "cal-"+realName, objID) if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, err) } return b.decodeObject(objPath, data) } func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { requester, localName, err := b.parseCalPath(ctx, calPath) if err != nil { return nil, err } if localName == birthdaysCalendarName { return b.listBirthdayCalendarObjects(requester) } if sub, err := b.dbase.GetICSSubscription(requester, localName); err == nil { return b.listICSSubscriptionCalendarObjects(localName, sub) } owner, realName, _, err := b.resolveCalendar(requester, localName, false) if err != nil { return nil, err } ids, err := b.store.ListObjects(owner, "cal-"+realName) if err != nil { return nil, err } var objs []caldav.CalendarObject for _, id := range ids { data, err := b.store.GetObject(owner, "cal-"+realName, id) if err != nil { continue } obj, err := b.decodeObject(calObjectPath(localName, id), data) if err != nil { b.logger.Warn("decoding calendar object", "id", id, "error", err) continue } objs = append(objs, *obj) } return objs, nil } func (b *Backend) QueryCalendarObjects(ctx context.Context, calPath string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { if query == nil { return b.ListCalendarObjects(ctx, calPath, nil) } // hoistPropFilterTimeRanges and closeOpenEndedTimeRanges are both // idempotent and only mutate a local shallow copy of the query's // comp-tree — the caller's original CalendarQuery is not visible // (query is a *CalendarQuery but we only walk the value-comp fields). hoistPropFilterTimeRanges(&query.CompFilter) closeOpenEndedTimeRanges(&query.CompFilter) all, err := b.ListCalendarObjects(ctx, calPath, &query.CompRequest) if err != nil { return nil, err } return caldav.Filter(query, all) } // hoistPropFilterTimeRanges rewrites a CompFilter in place, moving any // prop-filter time-ranges (typically // ) up to the enclosing comp-filter and clearing // them from the prop-filter. This is what fixes go-webdav's // non-recurrence-aware matchPropTimeRange (which only compares literal // DTSTART — so a weekly series whose base DTSTART is in 2025 gets dropped // for a 2026 query even though its RRULE has 2026 occurrences). // // Lifting the range onto the enclosing comp-filter is the RFC 4791 // 9.9-equivalent for recurring components: the comp-filter's time-range is // evaluated by go-webdav's matchCompTimeRange, which *does* expand RRULE // (comp.RecurrenceSet) and returns true if any occurrence falls in the // window. Non-recurring events are unaffected because the comp-level // time-range and the prop-level time-range both check against the same // DTSTART/DTEND. // // A small heuristic governs the merge: // // - parent has no range yet → parent.Start/End := child's range // - parent already has a range → parent's range wins (rare / ambiguous // client request), but the child's time-range is still cleared so // go-webdav's literal DTSTART check doesn't re-exclude recurring // series // // This runs for every comp-filter in the tree, regardless of depth, so // both VCALENDAR>VEVENT and any other nesting the client sends are // handled. func hoistPropFilterTimeRanges(cf *caldav.CompFilter) { if cf == nil { return } for i := range cf.Props { pf := &cf.Props[i] if pf.Start.IsZero() && pf.End.IsZero() { continue } if cf.Start.IsZero() { cf.Start = pf.Start } if cf.End.IsZero() { cf.End = pf.End } // Clear the prop-filter's own time-range so go-webdav's literal // DTSTART check (matchPropTimeRange) doesn't re-exclude a series // whose base DTSTART is outside the window but whose RRULE has // occurrences in it. pf.Start = time.Time{} pf.End = time.Time{} } for i := range cf.Comps { hoistPropFilterTimeRanges(&cf.Comps[i]) } } // farFutureSentinel stands in for "no upper bound" in an open-ended // (RFC 4791 §9.9 explicitly allows a // time-range with only a start attribute, meaning "everything from start // onward"). It's a fixed calendar date rather than e.g. time.Now() plus // some duration so behavior doesn't depend on when a request happens to // run; 2100 is comfortably beyond any realistic calendar subscription's // horizon while still bounding recurrence expansion to a finite, // fast-to-compute range. var farFutureSentinel = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC) // closeOpenEndedTimeRanges rewrites a CompFilter in place, replacing a // zero-value End on any comp-filter that has a non-zero Start with // farFutureSentinel. // // This works around a real bug (as of go-webdav v0.6.0): a client asking // for "everything from date X onward" sends a time-range with only a // start attribute, which decodes with End left as the zero time.Time. // For a *non*-recurring event, go-webdav's matchCompTimeRange correctly // treats a zero End as "unbounded" (it explicitly checks end.IsZero()). // But for a *recurring* event, it instead calls // rrule.Set.Between(start, end, true) unconditionally — and passing the // zero time.Time (year 1) as the upper bound there means "before start", // so Between always returns zero occurrences, silently excluding every // recurring series from an open-ended query. This is exactly the shape // of query many real CalDAV clients send for their default "sync events // from N days in the past, no future limit" setting (e.g. DAVx5) — so // without this workaround, a recurring series survives a bounded // time-range query (both start and end given) but vanishes the moment a // client asks for an unbounded future window, which is a common default. func closeOpenEndedTimeRanges(cf *caldav.CompFilter) { if cf == nil { return } if !cf.Start.IsZero() && cf.End.IsZero() { cf.End = farFutureSentinel } for i := range cf.Comps { closeOpenEndedTimeRanges(&cf.Comps[i]) } } func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { p := auth.FromContext(ctx) if p == nil { return webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } // Derive collection name from the trailing path segment. Calendars are // always created under the acting user's own namespace — sharing an // existing calendar is done via ShareCalendar, not by creating one // directly in someone else's name. name := path.Base(strings.TrimSuffix(calendar.Path, "/")) if err := b.dbase.CreateCalendar(p.Username, name); err != nil { if err == db.ErrReservedName { return webdav.NewHTTPError(http.StatusForbidden, err) } if err != db.ErrResourceExists { return fmt.Errorf("registering calendar: %w", err) } } return b.store.EnsureCollection(p.Username, "cal-"+name) } func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error { requester, localName, err := b.parseCalPath(ctx, calPath) if err != nil { return err } if localName == birthdaysCalendarName { return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically")) } // Unlike the birthdays calendar, an ICS subscription can be deleted: // that's how a user unsubscribes from it via CalDAV. if err := b.dbase.DeleteICSSubscription(requester, localName); err == nil { return nil } else if err != db.ErrResourceNotFound { return fmt.Errorf("unregistering ics subscription: %w", err) } owner, realName, _, err := b.resolveCalendar(requester, localName, true) if err != nil { return err } if err := b.dbase.DeleteCalendar(owner, realName); err != nil && err != db.ErrResourceNotFound { return fmt.Errorf("unregistering calendar: %w", err) } return b.store.DeleteCollection(owner, "cal-"+realName) } func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { requester, localName, objID, err := b.parseObjPath(ctx, objPath) if err != nil { return nil, err } if localName == birthdaysCalendarName { return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically")) } if _, err := b.dbase.GetICSSubscription(requester, localName); err == nil { return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("this calendar is a read-only ics/webcal subscription")) } owner, realName, _, err := b.resolveCalendar(requester, localName, true) if err != nil { return nil, err } var buf strings.Builder enc := ical.NewEncoder(&buf) if err := enc.Encode(calendar); err != nil { return nil, fmt.Errorf("encoding calendar: %w", err) } data := []byte(buf.String()) if err := b.store.PutObject(owner, "cal-"+realName, objID, data); err != nil { return nil, fmt.Errorf("storing calendar object: %w", err) } return b.decodeObject(objPath, data) } func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) error { requester, localName, objID, err := b.parseObjPath(ctx, objPath) if err != nil { return err } if localName == birthdaysCalendarName { return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically")) } if _, err := b.dbase.GetICSSubscription(requester, localName); err == nil { return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("this calendar is a read-only ics/webcal subscription")) } owner, realName, _, err := b.resolveCalendar(requester, localName, true) if err != nil { return err } if err := b.store.DeleteObject(owner, "cal-"+realName, objID); err != nil { return webdav.NewHTTPError(http.StatusNotFound, err) } return nil } // -------- helpers -------- // sharedCalendarName builds the synthetic local name a shared calendar is // exposed under to the user it was shared with. func sharedCalendarName(owner, calName string) string { return owner + sharedNameSep + calName } // resolveCalendar maps a local calendar name (as seen in a URL path by // requester) to its real owner and on-disk name, checking permissions // along the way. If localName contains sharedNameSep, it's treated as a // reference to another user's calendar and looked up in the shares table; // otherwise it's assumed to be one of requester's own calendars. // // If requireWrite is true, a share must grant PermWrite or this returns a // 403 Forbidden error. Owners always have full access to their own // calendars. func (b *Backend) resolveCalendar(requester, localName string, requireWrite bool) (owner, realName string, perm db.Permission, err error) { if ownerName, calName, ok := strings.Cut(localName, sharedNameSep); ok { if b.dbase == nil { return "", "", "", webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("sharing not enabled")) } share, err := b.dbase.CalendarShareFor(ownerName, calName, requester) if err != nil { return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("calendar not shared with you")) } if requireWrite && share.Permission != db.PermWrite { return "", "", "", webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("read-only share")) } return ownerName, calName, share.Permission, nil } return requester, localName, db.PermWrite, nil } func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calendar { return b.calendarMetaNamed(owner, realName, localName, realName) } // calendarMetaNamed is calendarMeta with an explicit displayName, used for // a calendar shared with the requester whose name collides with one of // their own calendars (see sharedDisplayName) — the Description still // always names the real calendar, only the client-visible Name changes. func (b *Backend) calendarMetaNamed(owner, realName, localName, displayName string) caldav.Calendar { desc := fmt.Sprintf("%s's %s calendar", b.dbase.DisplayName(owner), realName) return caldav.Calendar{ Path: calHomePath() + localName + "/", // The displayed name is always the plain calendar name (never the // "owner~name" synthetic local name used internally to keep a // shared calendar's URL unique) — clients like DAVx5 show this as // the calendar's label, and "alice~work" looked confusing there. Name: displayName, Description: desc, SupportedComponentSet: []string{"VEVENT", "VTODO", "VJOURNAL"}, MaxResourceSize: 10 * 1024 * 1024, // 10 MiB } } // sharedDisplayName returns realName, suffixed with the owning user's // name in parentheses (e.g. "Work (bob)") if requester has another // calendar with the exact same display name — either one of their own, // or another share from a different owner — so DAVx5 (and other clients) // don't show two calendars with an identical, indistinguishable title. // Only called for calendars shared with requester; requester's own // calendars never need disambiguating against themselves. func (b *Backend) sharedDisplayName(requester, owner, realName string) string { own, err := b.dbase.ListCalendars(requester) if err != nil { b.logger.Warn("listing own calendars for disambiguation", "error", err) } ownerLabel := b.dbase.DisplayName(owner) for _, c := range own { if c.Name == realName { return fmt.Sprintf("%s (%s)", realName, ownerLabel) } } shares, err := b.dbase.CalendarsSharedWith(requester) if err != nil { b.logger.Warn("listing shared calendars for disambiguation", "error", err) } for _, sh := range shares { if sh.Owner == owner && sh.CalendarName == realName { continue // the calendar itself, not a collision } if sh.CalendarName == realName { return fmt.Sprintf("%s (%s)", realName, ownerLabel) } } return realName } func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) { // Some clients (Outlook/Exchange, some Thunderbird/Lightning setups) // write Windows timezone names into TZID instead of IANA ones, which // go-ical can't resolve. A recurring event with such a TZID decodes // fine here but fails later, as a 500, the moment a CalDAV client // issues a time-range query that expands its recurrence — so fix it // up before decoding rather than only in QueryCalendarObjects. cal, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode() if err != nil { return nil, fmt.Errorf("decoding ical: %w", err) } etag := fmt.Sprintf(`"%x"`, hashBytes(data)) return &caldav.CalendarObject{ Path: objPath, ModTime: time.Now(), ContentLength: int64(len(data)), ETag: etag, Data: cal, }, nil } func (b *Backend) parseCalPath(ctx context.Context, calPath string) (user, calName string, err error) { p := auth.FromContext(ctx) if p == nil { return "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } user = p.Username parts := strings.Split(strings.Trim(calPath, "/"), "/") // expected: cal/home// if len(parts) < 3 { return "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid calendar path")) } calName = parts[2] return user, calName, nil } func (b *Backend) parseObjPath(ctx context.Context, objPath string) (user, calName, objID string, err error) { p := auth.FromContext(ctx) if p == nil { return "", "", "", webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) } user = p.Username parts := strings.Split(strings.Trim(objPath, "/"), "/") // expected: cal/home// if len(parts) < 4 { return "", "", "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid object path")) } calName = parts[2] objID = path.Base(objPath) return user, calName, objID, nil } // calPrincipalPath, calHomePath, and calObjectPath are the same for every // user: authorization is resolved from the Basic Auth identity, not from // the URL, so no username segment is needed in the path. // // The fixed "home" segment (in place of a username) is required, not // cosmetic: go-webdav's caldav server classifies a request purely by how // many path segments it has relative to the handler's mount point — 1 // segment is treated as the principal, 2 as the calendar-home-set, 3 as a // calendar, 4 as a calendar object. Removing that segment entirely would // make the home-set and calendar paths misclassified as principal/home-set // respectively, breaking discovery (empty PROPFIND responses). func calPrincipalPath() string { return "/cal/" } func calHomePath() string { return "/cal/home/" } func calObjectPath(calName, objID string) string { return fmt.Sprintf("/cal/home/%s/%s", calName, objID) } func hashBytes(data []byte) uint64 { var h uint64 = 14695981039346656037 for _, b := range data { h ^= uint64(b) h *= 1099511628211 } return h } // -------- calendar-color PROPFIND injection -------- // calCollectionPathRe matches a calendar collection's own path (e.g. // "/cal/home/work/"), as opposed to a calendar object inside it (e.g. // "/cal/home/work/abc123.ics") or the home-set/principal path. var calCollectionPathRe = regexp.MustCompile(`^/cal/home/[^/]+/$`) // colorInjectingHandler wraps a caldav.Handler and post-processes PROPFIND // responses to add the non-standard `calendar-color` property (in Apple's // "http://apple.com/ns/ical/" namespace) that DAVx5 and other clients read // to color-code synced calendars. go-webdav's caldav.Backend interface has // no extension point for vendor properties like this, so the response XML // is rewritten after the fact instead. type colorInjectingHandler struct { backend *Backend next http.Handler } func (h *colorInjectingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != "PROPFIND" || h.backend.dbase == nil { h.next.ServeHTTP(w, r) return } // Only bother rewriting if the client asked for calendar-color // specifically, or for all properties (propname/allprop requests, or // no body at all, are treated as "all properties" by most clients). var reqBody []byte if r.Body != nil { reqBody, _ = readAllAndReset(&r.Body) } wantsColor := len(reqBody) == 0 || bytes.Contains(reqBody, []byte("calendar-color")) || bytes.Contains(reqBody, []byte("allprop")) if !wantsColor { h.next.ServeHTTP(w, r) return } rec := httptest.NewRecorder() h.next.ServeHTTP(rec, r) for k, vs := range rec.Header() { for _, v := range vs { w.Header().Add(k, v) } } body := rec.Body.Bytes() if rec.Code == http.StatusMultiStatus && strings.Contains(rec.Header().Get("Content-Type"), "xml") { body = h.injectCalendarColors(r.Context(), body) w.Header().Set("Content-Length", strconv.Itoa(len(body))) } w.WriteHeader(rec.Code) _, _ = w.Write(body) } // responseBlockRe matches a single ... element // (non-greedy) inside a multistatus document, capturing its href. var responseBlockRe = regexp.MustCompile(`(?s)]*>.*?]*>([^<]*).*?`) // propstatBlockRe matches a single ... element // within a response, non-greedily. var propstatBlockRe = regexp.MustCompile(`(?s)]*>.*?`) // okPropRe matches the first opening tag inside a // 200 OK propstat block, used to find where to insert new property XML. var okPropRe = regexp.MustCompile(``) // injectCalendarColors scans a multistatus PROPFIND response body and, for // each whose href is a calendar collection with a color set, // inserts a element // into its first 200 OK . Since go-webdav's stock property map // doesn't know this property, a client that explicitly asks for it (as // DAVx5 does) gets back a 404 propstat for it — that bogus 404 entry is // stripped first, since leaving both a 404 and our injected 200 for the // same property name in one is invalid multistatus and // confuses clients (dav4jvm/DAVx5 was observed to keep showing no color // when both were present). func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body []byte) []byte { p := auth.FromContext(ctx) if p == nil { return body } return responseBlockRe.ReplaceAllFunc(body, func(block []byte) []byte { m := responseBlockRe.FindSubmatch(block) if m == nil { return block } href := string(m[1]) if !calCollectionPathRe.MatchString(href) { return block } localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/") var color string var err error if localName == birthdaysCalendarName { color, err = h.backend.dbase.GetBirthdayCalendarColor(p.Username) if err != nil { color = "" } if color == "" { color = defaultBirthdayColor } } else if sub, serr := h.backend.dbase.GetICSSubscription(p.Username, localName); serr == nil { color = sub.Color if color == "" { color = defaultICSColor } } else { owner, realName, _, rerr := h.backend.resolveCalendar(p.Username, localName, false) if rerr != nil { return block } color, err = h.backend.dbase.GetCalendarColor(owner, realName) if err != nil || color == "" { return block } } // Drop any propstat block that only complains calendar-color is // unknown (a 404/not-found propstat containing a bare, empty // calendar-color element), from either namespace clients might // have queried it in. block = propstatBlockRe.ReplaceAllFunc(block, func(ps []byte) []byte { if bytes.Contains(ps, []byte("calendar-color")) && !bytes.Contains(ps, []byte("200 OK")) { return nil } return ps }) idx := okPropRe.FindIndex(block) if idx == nil { return block } insertAt := idx[1] colorEl := []byte(fmt.Sprintf(`%s`, xmlEscapeColor(color))) out := make([]byte, 0, len(block)+len(colorEl)) out = append(out, block[:insertAt]...) out = append(out, colorEl...) out = append(out, block[insertAt:]...) return out }) } // xmlEscapeColor returns color formatted as an 8-digit ARGB/RGBA hex value // (as Apple's calendar-color property expects), padding a plain 6-digit // "#RRGGBB" (as produced by an HTML ) with a fully // opaque alpha channel. func xmlEscapeColor(color string) string { if len(color) == 7 && color[0] == '#' { return color + "FF" } return color } // readAllAndReset reads body fully and replaces it with a fresh reader over // the same bytes, so downstream handlers can still consume it. func readAllAndReset(body *io.ReadCloser) ([]byte, error) { data, err := io.ReadAll(*body) if err != nil { return nil, err } *body = io.NopCloser(bytes.NewReader(data)) return data, nil }