Files
nidus/internal/icssub/icssub.go
T

259 lines
8.0 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
}
// 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"
}