package caldav import ( "crypto/sha1" "encoding/hex" "fmt" "net/http" "strings" "time" ical "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" "git.arnef.de/arnef/nidus/internal/db" ) // defaultICSColor is the display color for an ICS/webcal subscription // calendar when the user hasn't chosen one, matching internal/web's. const defaultICSColor = "#0ea5e9" // icsSubscriptionCalendarMeta returns the caldav.Calendar metadata for // owner's ICS subscription sub, exposed under its own chosen name. func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscription) caldav.Calendar { return caldav.Calendar{ Path: calHomePath() + sub.Name + "/", Name: sub.Name, Description: "Read-only subscription: " + sub.URL, SupportedComponentSet: []string{"VEVENT"}, MaxResourceSize: 256 * 1024, } } // icsObjectUID returns the UID a fetched VEVENT should be addressed by: // its own UID property if it has one, otherwise a stable hash of its // position so it still round-trips consistently between requests. func icsObjectUID(ev ical.Event, fallback string) string { if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" { return p.Value } return fallback } // icsObjID builds the object ID (file-name-like, ".ics" suffixed) used to // address a fetched VEVENT within its subscription calendar, derived from // its UID so it stays stable across fetches of the same feed. func icsObjID(uid string) string { sum := sha1.Sum([]byte(uid)) return hex.EncodeToString(sum[:]) + ".ics" } // listICSSubscriptionCalendarObjects fetches sub's remote calendar (via // b.icsCache) and returns one caldav.CalendarObject per VEVENT. func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) { cal, err := b.icsCache.Get(sub.URL) if err != nil { return nil, fmt.Errorf("fetching ics subscription %q: %w", sub.Name, err) } var objs []caldav.CalendarObject for i, ev := range cal.Events() { obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i)) if err != nil { continue } objs = append(objs, *obj) } return objs, nil } // icsSubscriptionCalendarObject fetches sub's remote calendar and returns // the single VEVENT whose derived object ID matches objID. func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.ICSSubscription) (*caldav.CalendarObject, error) { cal, err := b.icsCache.Get(sub.URL) if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err)) } for i, ev := range cal.Events() { uid := icsObjectUID(ev, fmt.Sprintf("event-%d", i)) if icsObjID(uid) != objID { continue } return b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i)) } return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found")) } // encodeICSObject wraps a single fetched VEVENT ev into its own // caldav.CalendarObject, encoding it as a standalone one-event calendar // the same way every other calendar object in this backend is // represented. fallbackUID is used to derive the object ID/UID if ev has // no UID property of its own. func (b *Backend) encodeICSObject(localName string, ev ical.Event, fallbackUID string) (*caldav.CalendarObject, error) { uid := icsObjectUID(ev, fallbackUID) objID := icsObjID(uid) event := ical.NewEvent() event.Props = ev.Props if event.Props.Get(ical.PropUID) == nil { event.Props.SetText(ical.PropUID, uid) } if event.Props.Get(ical.PropDateTimeStamp) == nil { event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) } out := ical.NewCalendar() out.Props.SetText(ical.PropVersion, "2.0") out.Props.SetText(ical.PropProductID, "-//nidus//ics-subscription//EN") out.Children = append(out.Children, event.Component) var buf strings.Builder if err := ical.NewEncoder(&buf).Encode(out); err != nil { return nil, fmt.Errorf("encoding ics subscription event: %w", err) } data := []byte(buf.String()) return &caldav.CalendarObject{ Path: calObjectPath(localName, objID), ModTime: time.Now(), ContentLength: int64(len(data)), ETag: fmt.Sprintf(`"ics-%s"`, objID), Data: out, }, nil }