// 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 } // 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" }