496 lines
16 KiB
Go
496 lines
16 KiB
Go
// 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 <b>base</b> VEVENT carrying the RRULE, plus one bare
|
|
// <b>instance</b> 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("<DTSTART-value>|<duration>|<SUMMARY>") 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"
|
|
}
|