// Package icssub fetches and caches remote ICS/webcal calendars, shared by // the web calendar UI (internal/web) and the CalDAV backend // (internal/caldav) so both render identical events for a user's ICS // subscriptions without duplicating fetch/parse/cache logic. package icssub import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "strings" "sync" "time" ical "github.com/emersion/go-ical" "git.arnef.de/arnef/nidus/internal/icalfix" ) // DefaultTTL is how long a fetched calendar is considered "fresh" before a // Get will kick off a background refresh. const DefaultTTL = 15 * time.Minute // fetchTimeout bounds how long a single upstream request may take, so one // slow/unreachable subscription can't stall a page render indefinitely. const fetchTimeout = 15 * time.Second // maxBodySize caps how much of a remote calendar is read, guarding // against a malicious or misconfigured URL streaming an unbounded // response. const maxBodySize = 32 * 1024 * 1024 // 32 MiB // entry holds everything the Cache knows about a single upstream URL. All // fields are only read/written while holding Cache.mu. type entry struct { url string // original URL as supplied by the caller (fetch normalizes) // cal is the most recent successfully-fetched calendar. Nil until the // first successful fetch for this URL. cal *ical.Calendar // lastErr is the most recent fetch error. Set alongside cal == nil // (i.e. no successful fetch yet); cleared the moment a fetch succeeds. lastErr error // refreshing is true while a fetch (foreground, or background refresh) // is in flight for this URL. refreshing bool // pending is the completion channel for the in-flight fetch. Only valid // while refreshing is true; it is created fresh for each fetch and // closed exactly once when that fetch finishes. Callers that see // refreshing==true read this channel (under the lock) and wait on it. pending chan struct{} // fetchedAt is the wall-clock time of the most recent fetch attempt // (success or failure), used for the TTL freshness check. fetchedAt time.Time } // Cache fetches remote ICS calendars over HTTP(S), keeping a shared // in-memory copy per URL. Semantics: // // - Fresh entry (fetchedAt within TTL): return immediately, no I/O. // - Stale entry with a cached copy: return the stale copy immediately // AND spawn at most one background refresher (other callers in the // same window piggyback on the in-flight refresh). // - Stale entry with no cached copy (prior fetch failed): return the // cached error immediately AND spawn a background retry. // - No entry at all (very first call for this URL): block until a // foreground fetch finishes (concurrent first-callers wait on a shared // channel and all get the same result) and return its data. type Cache struct { ttl time.Duration client *http.Client mu sync.Mutex urls map[string]*entry // keyed by normalizeURL(url) } // NewCache creates a Cache with the given TTL (use DefaultTTL if unsure). func NewCache(ttl time.Duration) *Cache { return &Cache{ ttl: ttl, client: &http.Client{}, urls: make(map[string]*entry), } } // Get returns the most recently successfully-fetched calendar for url, or // the most-recent fetch error if no successful copy exists yet (a // background refresher may already be retrying). func (c *Cache) Get(url string) (*ical.Calendar, error) { key := normalizeURL(url) c.mu.Lock() e := c.urls[key] if e == nil { e = &entry{url: url} c.urls[key] = e } switch { case e.cal == nil && e.lastErr == nil && !e.refreshing: // Very first request for this URL: do a foreground fetch. e.refreshing = true e.pending = make(chan struct{}) ch := e.pending c.mu.Unlock() go c.doFetch(e, ch) <-ch return c.snapshot(e) case e.cal == nil && e.lastErr == nil: // A foreground fetch is already in flight — wait for it. ch := e.pending c.mu.Unlock() <-ch return c.snapshot(e) default: // We have some data (a cached copy or a cached error). if time.Since(e.fetchedAt) < c.ttl { // Fresh — just return. c.mu.Unlock() return c.snapshot(e) } // Stale — return the cached value immediately; spawn at most one // background refresher (or piggyback on one already in flight). if !e.refreshing { e.refreshing = true ch := make(chan struct{}) e.pending = ch c.mu.Unlock() go c.doFetch(e, ch) } else { c.mu.Unlock() } return c.snapshot(e) } } // doFetch performs the network I/O for the entry, updates cal/lastErr and // the freshness timestamp under the lock, clears the in-flight state, and // closes the per-fetch completion channel exactly once. func (c *Cache) doFetch(e *entry, ch chan struct{}) { cal, err := c.fetch(e.url) c.mu.Lock() e.fetchedAt = time.Now() if err == nil { e.cal = cal e.lastErr = nil } else { e.lastErr = err } e.refreshing = false e.pending = nil c.mu.Unlock() close(ch) } // snapshot reads e.cal/e.lastErr under c.mu and returns the same value // shape Get does. Callers must not hold c.mu. func (c *Cache) snapshot(e *entry) (*ical.Calendar, error) { c.mu.Lock() cal, err := e.cal, e.lastErr c.mu.Unlock() if cal != nil { return cal, nil } return nil, err } // fetch downloads and parses url, translating a "webcal://" scheme (used // by some calendar-subscription links) to "https://" first, since Go's // http.Client has no built-in handler for it. func (c *Cache) fetch(rawURL string) (*ical.Calendar, error) { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizeURL(rawURL), nil) if err != nil { return nil, fmt.Errorf("building request: %w", err) } req.Header.Set("Accept", "text/calendar, */*") req.Header.Set("User-Agent", "nidus-ics-subscription/1.0") resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("fetching calendar: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetching calendar: unexpected status %s", resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize)) if err != nil { return nil, fmt.Errorf("reading calendar: %w", err) } // Some remote feeds (Outlook/Exchange-backed ones especially) use // Windows timezone names instead of IANA ones, which go-ical can't // resolve — fix those up before decoding (see internal/icalfix). cal, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(body))).Decode() if err != nil { return nil, fmt.Errorf("parsing calendar: %w", err) } return cal, nil } // normalizeURL rewrites a "webcal://" URL to "https://" so it can be // fetched with a normal HTTP client. func normalizeURL(u string) string { if rest, ok := strings.CutPrefix(u, "webcal://"); ok { return "https://" + rest } return u } // Series is one addressable calendar entity in a fetched ICS/calendar // subscription feed: all VEVENTs that share the same UID, or — for feeds // that omit UIDs — that hash to the same EventID, grouped together. // // This matches how Outlook / Exchange publish recurring events ("fully // expanded" ICS): one base VEVENT carrying the RRULE, plus one bare // instance VEVENT per explicit occurrence (per-instance edits, // one-off additions, "Canceled:" overrides) — all under the SAME UID. // Treating the base and each instance as separate events is what produces // the two visible bugs, so everything on the subscription path // (web grid, CalDAV objects, detail page) is keyed off the Series, not off // individual VEVENTs: // // - the web month/week grid paints ONE entry per (series, day), so a day // covered by both the RRULE base and an explicit instance is not // rendered twice; // - the CalDAV backend advertises ONE calendar object per series, whose // VCALENDAR holds the base + all instances — the shape the source feed // uses — so clients keep the whole series instead of dropping it. // // A series has at least one of Base or a non-empty Instances; Key is // stable across fetches and is the only thing callers address it by. type Series struct { Key string Base *ical.Event // the VEVENT carrying the RRULE, if any Instances []ical.Event // the explicit per-occurrence VEVENTs // tzs holds every VTIMEZONE component from the source feed (shared // across all series parsed from the same feed). Calendar() embeds // whichever of these are actually referenced by this series' VEVENTs, // so the synthetic per-series VCALENDAR stays RFC 5545-compliant. See // Calendar's doc comment for why this matters. tzs []*ical.Component } // ID returns a stable filesystem-name/URL-path identifier for the series // (32 hex chars + ".ics"), derived from the series Key. Two different // series never collide; the same series keeps the same ID across // refetches of the same feed. func (s *Series) ID() string { sum := sha256.Sum256([]byte("series:" + s.Key)) return hex.EncodeToString(sum[:16]) + ".ics" } // GroupSeries folds all VEVENTs of cal into Series, one per UID (falling // back to EventID for feeds without UIDs). Within a series the VEVENT that // carries an RRULE becomes Base; every other VEVENT is an Instance. The // returned slice preserves the feed's first-seen order of each series. func GroupSeries(cal *ical.Calendar) []*Series { if cal == nil { return nil } // Collect every VTIMEZONE the feed defines, so each series can embed // whichever ones its own VEVENTs actually reference (see Calendar). var tzs []*ical.Component for _, child := range cal.Children { if child.Name == ical.CompTimezone { tzs = append(tzs, child) } } byKey := make(map[string]*Series) var order []string for _, ev := range cal.Events() { var key string if uid := ev.Props.Get(ical.PropUID); uid != nil && uid.Value != "" { key = "uid=" + uid.Value } else if id := EventID(ev); id != "" { key = "eid=" + id } else { continue // not addressable } s := byKey[key] if s == nil { s = &Series{Key: key, tzs: tzs} byKey[key] = s order = append(order, key) } if ev.Props.Get(ical.PropRecurrenceRule) != nil { if s.Base == nil { s.Base = &ev } } else { s.Instances = append(s.Instances, ev) } } out := make([]*Series, 0, len(order)) for _, key := range order { out = append(out, byKey[key]) } return out } // Anchor returns the VEVENT that best represents the series for display of // a single occurrence: the recurring Base when present, otherwise the // earliest Instance. It is nil for an empty series. func (s *Series) Anchor() *ical.Event { if s.Base != nil { return s.Base } best := -1 for i := range s.Instances { if best == -1 || dtStartEarlier(s.Instances[i], s.Instances[best]) { best = i } } if best == -1 { return nil } return &s.Instances[best] } // Calendar returns a fresh, self-contained ical.Calendar holding every // VEVENT of the series (base first, then instances in feed order), i.e. the // source feed's own group of events — suitable to encode as one CalDAV // calendar object and for the subscription detail page. // // It also embeds every VTIMEZONE component (copied from the source feed) // that's actually referenced by one of the series' own VEVENTs (via a // TZID parameter on DTSTART/DTEND/RECURRENCE-ID/EXDATE/RDATE). Per RFC // 5545 §3.6.5, a TZID that isn't "UTC" or a bare offset MUST have a // matching VTIMEZONE definition in the same iCalendar object. Without it, // strict parsers (notably ical4j, which DAVx5 is built on) can fail to // resolve the timezone for recurrence-rule expansion and silently drop // the whole VEVENT — which is exactly what made recurring events // (e.g. a weekly meeting) vanish from CalDAV clients even though the // event listing/query logic itself was correct: each series used to be // encoded as a bare VEVENT-only VCALENDAR with no VTIMEZONE at all. func (s *Series) Calendar() *ical.Calendar { out := ical.NewCalendar() out.Props.SetText(ical.PropVersion, "2.0") out.Props.SetText(ical.PropProductID, "-//nidus//ics-subscription//EN") for _, tz := range s.referencedTimezones() { out.Children = append(out.Children, tz) } if s.Base != nil { out.Children = append(out.Children, s.Base.Component) } for i := range s.Instances { out.Children = append(out.Children, s.Instances[i].Component) } return out } // referencedTimezones returns the subset of s.tzs whose TZID is // referenced by any property of s.Base or s.Instances, preserving s.tzs' // original order and including each matched VTIMEZONE at most once. func (s *Series) referencedTimezones() []*ical.Component { if len(s.tzs) == 0 { return nil } needed := make(map[string]bool) note := func(ev *ical.Event) { if ev == nil { return } for _, props := range ev.Props { for _, p := range props { if tzid := p.Params.Get(ical.PropTimezoneID); tzid != "" { needed[tzid] = true } } } } note(s.Base) for i := range s.Instances { note(&s.Instances[i]) } if len(needed) == 0 { return nil } var out []*ical.Component for _, tz := range s.tzs { tzidProp := tz.Props.Get(ical.PropTimezoneID) if tzidProp == nil || !needed[tzidProp.Value] { continue } out = append(out, tz) } return out } // OccurrencesIn returns, for every calendar day (formatted with layout // "2006-01-02", in loc) in [gridStart, gridEnd] that the series occupies, // the single VEVENT representing the series on that day. The recurring // base (RRULE expansion) supplies the day's event, but an explicit // instance on the same day takes precedence (Exchange's "override" model: // this is how per-instance edits and "Canceled:" entries replace the // series occurrence for that day). At most one event per day is ever // returned, so callers can paint exactly one grid cell per day. func (s *Series) OccurrencesIn(gridStart, gridEnd time.Time, loc *time.Location) map[string]ical.Event { const layout = "2006-01-02" out := make(map[string]ical.Event) note := func(t0 time.Time, ev ical.Event) { out[t0.In(loc).Format(layout)] = ev } if s.Base != nil { if rset, err := s.Base.RecurrenceSet(loc); err == nil && rset != nil { for _, occ := range rset.Between(gridStart, gridEnd, true) { note(occ, *s.Base) } } } for i := range s.Instances { dtp := s.Instances[i].Props.Get(ical.PropDateTimeStart) if dtp == nil { continue } t0, err := dtp.DateTime(loc) if err != nil || t0.Before(gridStart) || t0.After(gridEnd) { continue } note(t0, s.Instances[i]) } return out } // dtStartEarlier reports whether a's DTSTART value sorts before b's // (string compare of the raw property value is sufficient for a // deterministic tie-break used by Anchor). func dtStartEarlier(a, b ical.Event) bool { av := a.Props.Get(ical.PropDateTimeStart) bv := b.Props.Get(ical.PropDateTimeStart) switch { case av == nil: return false case bv == nil: return true } return av.Value < bv.Value } // EventID returns a stable, short identifier for a single ical.Event, // suitable for use as a filesystem object name or URL path segment. It is // the first 32 hex chars (128 bits) of // sha256("||") with a ".ics" suffix. // // Two events with the same DTSTART, same duration, and same SUMMARY hash // to the same ID — this matches the addressing scheme used both by the // web detail view and the CalDAV backend for ICS-subscription events. // Returns "" if DTSTART is missing (not addressable). func EventID(ev ical.Event) string { start := ev.Props.Get(ical.PropDateTimeStart) if start == nil { return "" } dur := "" if end := ev.Props.Get(ical.PropDateTimeEnd); end != nil { if s, err := start.DateTime(time.UTC); err == nil { if e, err := end.DateTime(time.UTC); err == nil { dur = e.Sub(s).Round(time.Second).String() } } } summary := "" if p := ev.Props.Get(ical.PropSummary); p != nil { summary = p.Value } var keyBuf strings.Builder keyBuf.WriteString(start.Value) keyBuf.WriteRune('|') keyBuf.WriteString(dur) keyBuf.WriteRune('|') keyBuf.WriteString(summary) sum := sha256.Sum256([]byte(keyBuf.String())) return hex.EncodeToString(sum[:16]) + ".ics" }