Files
nidus/internal/icssub/icssub.go
T
arnefandCopilot 213b22f821 Fix 500 on REPORT for recurring events with Windows timezone IDs
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>
2026-08-20 22:23:11 +02:00

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
}