// 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" "fmt" "io" "net/http" "strings" "sync" "time" ical "github.com/emersion/go-ical" "github.com/yourusername/caldav-server/internal/icalfix" ) // DefaultTTL is how long a fetched calendar is cached before being // re-fetched on the next access. 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 type entry struct { fetchedAt time.Time cal *ical.Calendar err error } // Cache fetches remote ICS calendars over HTTP(S), keeping a short-lived // in-memory copy per URL so repeated renders (e.g. every month-view page // load, or CalDAV client polling) don't re-fetch the same subscription // from origin every time. type Cache struct { ttl time.Duration client *http.Client mu sync.Mutex entries map[string]entry } // 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{Timeout: fetchTimeout}, entries: make(map[string]entry), } } // Get returns the parsed calendar fetched from url, using a cached copy // if it's still within the TTL. If a fresh fetch fails but a previously // fetched copy exists, the stale copy is returned instead of the error, // so a transient network issue doesn't blank out the calendar entirely. func (c *Cache) Get(url string) (*ical.Calendar, error) { c.mu.Lock() e, ok := c.entries[url] fresh := ok && time.Since(e.fetchedAt) < c.ttl c.mu.Unlock() if fresh { return e.cal, e.err } cal, err := c.fetch(url) c.mu.Lock() defer c.mu.Unlock() if err != nil && ok && e.cal != nil { return e.cal, nil } c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err} return cal, 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 }