Files
nidus/internal/caldav/backend.go
T
2026-08-29 19:16:12 +02:00

684 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package caldav
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path"
"regexp"
"strconv"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/auth"
"github.com/yourusername/caldav-server/internal/config"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/icssub"
"github.com/yourusername/caldav-server/internal/store"
)
// 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. dbase may be nil, in which case
// calendar sharing is disabled (only a user's own calendars are visible).
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend {
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)}
}
// 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) http.Handler {
b := NewBackend(cfg, st, dbase, logger)
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/<user>/ 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, "col/calendars/"+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["col/calendars/"+cal.Name] = true
}
for _, dir := range disk {
if strings.HasPrefix(dir, "col/calendars/") && !configured[dir] {
name := strings.TrimPrefix(dir, "col/calendars/")
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) {
// List all and filter sufficient for small collections.
all, err := b.ListCalendarObjects(ctx, calPath, &query.CompRequest)
if err != nil {
return nil, err
}
return caldav.Filter(query, all)
}
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/<calname>/
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/<calname>/<objid>
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 <response>...</response> element
// (non-greedy) inside a multistatus document, capturing its href.
var responseBlockRe = regexp.MustCompile(`(?s)<response[^>]*>.*?<href[^>]*>([^<]*)</href>.*?</response>`)
// propstatBlockRe matches a single <propstat>...</propstat> element
// within a response, non-greedily.
var propstatBlockRe = regexp.MustCompile(`(?s)<propstat[^>]*>.*?</propstat>`)
// okPropRe matches the first <prop xmlns="DAV:"> opening tag inside a
// 200 OK propstat block, used to find where to insert new property XML.
var okPropRe = regexp.MustCompile(`<prop xmlns="DAV:">`)
// injectCalendarColors scans a multistatus PROPFIND response body and, for
// each <response> whose href is a calendar collection with a color set,
// inserts a <calendar-color xmlns="http://apple.com/ns/ical/"> element
// into its first 200 OK <prop>. 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 <response> 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(`<calendar-color xmlns="http://apple.com/ns/ical/">%s</calendar-color>`, 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 <input type="color">) 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
}