DAVx5 reported a 500 Internal Server Error on a time-range REPORT against a calendar containing a recurring event whose DTSTART/DTEND used a Windows-style TZID (e.g. "W. Europe Standard Time", as commonly written by Outlook/Exchange and some Thunderbird/Lightning setups) instead of an IANA zone name. go-ical resolves TZID via a plain time.LoadLocation call, which only understands IANA names. A simple decode of such an event succeeds (RRULE dates aren't parsed eagerly), but expanding its recurrence - which go-webdav's caldav.Filter does for every time-range REPORT - calls Component.RecurrenceSet, which does call time.LoadLocation(tzid) and fails with "ical: error parsing start time: unknown time zone ...". Add internal/icalfix, a small shared helper that rewrites recognized Windows timezone identifiers (TZID parameters and VTIMEZONE TZID: lines) to their IANA equivalent in raw ICS bytes before decoding. Wire it into every ical.NewDecoder call site: internal/caldav/backend.go's decodeObject (fixes the reported bug), internal/web/calendar.go's event rendering/ICS import, and internal/icssub's remote feed fetching, so a subscribed feed with the same issue doesn't hit it either. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
131 lines
3.9 KiB
Go
131 lines
3.9 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"
|
|
"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
|
|
}
|