diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index e2f4120..5326d81 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -20,6 +20,7 @@ import ( "github.com/yourusername/caldav-server/internal/auth" "github.com/yourusername/caldav-server/internal/config" "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/icalfix" "github.com/yourusername/caldav-server/internal/icssub" "github.com/yourusername/caldav-server/internal/store" ) @@ -383,7 +384,13 @@ func (b *Backend) calendarMeta(owner, realName, localName string) caldav.Calenda } func (b *Backend) decodeObject(objPath string, data []byte) (*caldav.CalendarObject, error) { - cal, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() + // Some clients (Outlook/Exchange, some Thunderbird/Lightning setups) + // write Windows timezone names into TZID instead of IANA ones, which + // go-ical can't resolve. A recurring event with such a TZID decodes + // fine here but fails later, as a 500, the moment a CalDAV client + // issues a time-range query that expands its recurrence — so fix it + // up before decoding rather than only in QueryCalendarObjects. + cal, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode() if err != nil { return nil, fmt.Errorf("decoding ical: %w", err) } diff --git a/internal/icalfix/icalfix.go b/internal/icalfix/icalfix.go new file mode 100644 index 0000000..1920e2e --- /dev/null +++ b/internal/icalfix/icalfix.go @@ -0,0 +1,201 @@ +// Package icalfix normalizes non-standard timezone identifiers in raw +// iCalendar data before it's handed to go-ical's decoder. +// +// go-ical resolves a DTSTART/DTEND's TZID parameter (and, for recurring +// events, the RRULE expansion's start time) via a direct +// time.LoadLocation(tzid) call — see +// github.com/emersion/go-ical@.../ical.go's Prop.DateTime and +// components.go's Component.RecurrenceSet. That only understands IANA +// zone names ("Europe/Berlin"), but Windows/Outlook (and anything built +// on the Windows timezone database, e.g. Exchange, Thunderbird's Lightning +// on some setups) commonly emits Windows zone names instead +// ("W. Europe Standard Time"), which time.LoadLocation doesn't recognize. +// A recurring event stored with such a TZID decodes fine (go-ical doesn't +// eagerly parse RRULE dates), but fails later — as a 500 — the moment a +// CalDAV client issues a time-range REPORT that has to expand its +// recurrence, since that's when RecurrenceSet actually calls +// time.LoadLocation. +package icalfix + +import "regexp" + +// tzidParamRe matches a "TZID=" (optionally quoted) parameter value +// as it appears attached to any property (e.g. "DTSTART;TZID=W. Europe +// Standard Time:..."). The name runs until the next ':' or ';' delimiter +// or line break, so it captures the parameter's full value even though it +// contains spaces and periods. +var tzidParamRe = regexp.MustCompile(`TZID=("?)([^";:\r\n]+)("?)`) + +// tzidLineRe matches a standalone "TZID:" property line, as found +// inside a VTIMEZONE component. +var tzidLineRe = regexp.MustCompile(`(?m)^TZID:([^\r\n]+)$`) + +// NormalizeTimeZones rewrites any recognized Windows timezone identifier +// in data to its IANA equivalent, leaving everything else (including +// unrecognized TZIDs) untouched. It's safe to call on data that has no +// Windows TZIDs at all — such data passes through unchanged. +func NormalizeTimeZones(data []byte) []byte { + data = tzidParamRe.ReplaceAllFunc(data, func(m []byte) []byte { + sub := tzidParamRe.FindSubmatch(m) + name := string(sub[2]) + iana, ok := windowsToIANA[name] + if !ok { + return m + } + return []byte("TZID=" + string(sub[1]) + iana + string(sub[3])) + }) + data = tzidLineRe.ReplaceAllFunc(data, func(m []byte) []byte { + sub := tzidLineRe.FindSubmatch(m) + name := string(sub[1]) + iana, ok := windowsToIANA[name] + if !ok { + return m + } + return []byte("TZID:" + iana) + }) + return data +} + +// windowsToIANA maps common Windows timezone identifiers (as found in the +// CLDR windowsZones.xml "001"/default territory mapping) to an IANA zone +// name recognized by Go's tzdata. It's not exhaustive, but covers the +// zones seen in practice from Outlook/Exchange-generated ICS data. +var windowsToIANA = map[string]string{ + "Dateline Standard Time": "Etc/GMT+12", + "UTC-11": "Etc/GMT+11", + "Aleutian Standard Time": "America/Adak", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Marquesas Standard Time": "Pacific/Marquesas", + "Alaskan Standard Time": "America/Anchorage", + "UTC-09": "Etc/GMT+9", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "UTC-08": "Etc/GMT+8", + "Pacific Standard Time": "America/Los_Angeles", + "US Mountain Standard Time": "America/Phoenix", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Mountain Standard Time": "America/Denver", + "Central America Standard Time": "America/Guatemala", + "Central Standard Time": "America/Chicago", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Canada Central Standard Time": "America/Regina", + "SA Pacific Standard Time": "America/Bogota", + "Eastern Standard Time (Mexico)": "America/Cancun", + "Eastern Standard Time": "America/New_York", + "Haiti Standard Time": "America/Port-au-Prince", + "Cuba Standard Time": "America/Havana", + "US Eastern Standard Time": "America/Indianapolis", + "Turks And Caicos Standard Time": "America/Grand_Turk", + "Paraguay Standard Time": "America/Asuncion", + "Atlantic Standard Time": "America/Halifax", + "Venezuela Standard Time": "America/Caracas", + "Central Brazilian Standard Time": "America/Cuiaba", + "SA Western Standard Time": "America/La_Paz", + "Pacific SA Standard Time": "America/Santiago", + "Newfoundland Standard Time": "America/St_Johns", + "Tocantins Standard Time": "America/Araguaina", + "E. South America Standard Time": "America/Sao_Paulo", + "SA Eastern Standard Time": "America/Cayenne", + "Argentina Standard Time": "America/Buenos_Aires", + "Greenland Standard Time": "America/Godthab", + "Montevideo Standard Time": "America/Montevideo", + "Magallanes Standard Time": "America/Punta_Arenas", + "Saint Pierre Standard Time": "America/Miquelon", + "Bahia Standard Time": "America/Bahia", + "UTC-02": "Etc/GMT+2", + "Azores Standard Time": "Atlantic/Azores", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "UTC": "Etc/UTC", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "Sao Tome Standard Time": "Africa/Sao_Tome", + "Morocco Standard Time": "Africa/Casablanca", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Central European Standard Time": "Europe/Warsaw", + "W. Central Africa Standard Time": "Africa/Lagos", + "Jordan Standard Time": "Asia/Amman", + "GTB Standard Time": "Europe/Bucharest", + "Middle East Standard Time": "Asia/Beirut", + "Egypt Standard Time": "Africa/Cairo", + "E. Europe Standard Time": "Europe/Chisinau", + "Syria Standard Time": "Asia/Damascus", + "West Bank Standard Time": "Asia/Hebron", + "South Africa Standard Time": "Africa/Johannesburg", + "FLE Standard Time": "Europe/Kiev", + "Israel Standard Time": "Asia/Jerusalem", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Sudan Standard Time": "Africa/Khartoum", + "Libya Standard Time": "Africa/Tripoli", + "Namibia Standard Time": "Africa/Windhoek", + "Arabic Standard Time": "Asia/Baghdad", + "Turkey Standard Time": "Europe/Istanbul", + "Arab Standard Time": "Asia/Riyadh", + "Belarus Standard Time": "Europe/Minsk", + "Russian Standard Time": "Europe/Moscow", + "E. Africa Standard Time": "Africa/Nairobi", + "Volgograd Standard Time": "Europe/Volgograd", + "Iran Standard Time": "Asia/Tehran", + "Arabian Standard Time": "Asia/Dubai", + "Astrakhan Standard Time": "Europe/Astrakhan", + "Azerbaijan Standard Time": "Asia/Baku", + "Russia Time Zone 3": "Europe/Samara", + "Mauritius Standard Time": "Indian/Mauritius", + "Saratov Standard Time": "Europe/Saratov", + "Georgian Standard Time": "Asia/Tbilisi", + "Caucasus Standard Time": "Asia/Yerevan", + "Afghanistan Standard Time": "Asia/Kabul", + "West Asia Standard Time": "Asia/Tashkent", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "Pakistan Standard Time": "Asia/Karachi", + "Qyzylorda Standard Time": "Asia/Qyzylorda", + "India Standard Time": "Asia/Calcutta", + "Sri Lanka Standard Time": "Asia/Colombo", + "Nepal Standard Time": "Asia/Katmandu", + "Central Asia Standard Time": "Asia/Almaty", + "Bangladesh Standard Time": "Asia/Dhaka", + "Omsk Standard Time": "Asia/Omsk", + "Myanmar Standard Time": "Asia/Rangoon", + "SE Asia Standard Time": "Asia/Bangkok", + "Altai Standard Time": "Asia/Barnaul", + "W. Mongolia Standard Time": "Asia/Hovd", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Tomsk Standard Time": "Asia/Tomsk", + "China Standard Time": "Asia/Shanghai", + "North Asia East Standard Time": "Asia/Irkutsk", + "Singapore Standard Time": "Asia/Singapore", + "W. Australia Standard Time": "Australia/Perth", + "Taipei Standard Time": "Asia/Taipei", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "Aus Central W. Standard Time": "Australia/Eucla", + "Transbaikal Standard Time": "Asia/Chita", + "Tokyo Standard Time": "Asia/Tokyo", + "North Korea Standard Time": "Asia/Pyongyang", + "Korea Standard Time": "Asia/Seoul", + "Yakutsk Standard Time": "Asia/Yakutsk", + "Cen. Australia Standard Time": "Australia/Adelaide", + "AUS Central Standard Time": "Australia/Darwin", + "E. Australia Standard Time": "Australia/Brisbane", + "AUS Eastern Standard Time": "Australia/Sydney", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Tasmania Standard Time": "Australia/Hobart", + "Vladivostok Standard Time": "Asia/Vladivostok", + "Lord Howe Standard Time": "Australia/Lord_Howe", + "Bougainville Standard Time": "Pacific/Bougainville", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Magadan Standard Time": "Asia/Magadan", + "Norfolk Standard Time": "Pacific/Norfolk", + "Sakhalin Standard Time": "Asia/Sakhalin", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Russia Time Zone 11": "Asia/Kamchatka", + "New Zealand Standard Time": "Pacific/Auckland", + "UTC+12": "Etc/GMT-12", + "Fiji Standard Time": "Pacific/Fiji", + "Kamchatka Standard Time": "Asia/Kamchatka", + "Chatham Islands Standard Time": "Pacific/Chatham", + "UTC+13": "Etc/GMT-13", + "Tonga Standard Time": "Pacific/Tongatapu", + "Samoa Standard Time": "Pacific/Apia", + "Line Islands Standard Time": "Pacific/Kiritimati", +} diff --git a/internal/icalfix/icalfix_test.go b/internal/icalfix/icalfix_test.go new file mode 100644 index 0000000..abe120a --- /dev/null +++ b/internal/icalfix/icalfix_test.go @@ -0,0 +1,71 @@ +package icalfix + +import ( + "bytes" + "testing" + + ical "github.com/emersion/go-ical" +) + +// TestNormalizeTimeZonesFixesRecurringEvent reproduces the bug reported by +// a DAVx5 client: a recurring VEVENT with a Windows-style TZID +// ("W. Europe Standard Time") decodes fine, but expanding its recurrence +// via RecurrenceSet (as go-webdav's caldav.Filter does for a time-range +// REPORT) fails with "unknown time zone" because go-ical resolves TZID +// via a plain time.LoadLocation call. Normalizing the TZID to its IANA +// equivalent before decoding must make that expansion succeed. +func TestNormalizeTimeZonesFixesRecurringEvent(t *testing.T) { + const raw = "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//Test//Test//EN\r\n" + + "BEGIN:VTIMEZONE\r\n" + + "TZID:W. Europe Standard Time\r\n" + + "END:VTIMEZONE\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:recurring@example.com\r\n" + + "DTSTAMP:20240101T000000Z\r\n" + + "DTSTART;TZID=W. Europe Standard Time:20260522T220000\r\n" + + "DTEND;TZID=W. Europe Standard Time:20260522T230000\r\n" + + "RRULE:FREQ=WEEKLY;COUNT=5\r\n" + + "SUMMARY:Weekly sync\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + + normalized := NormalizeTimeZones([]byte(raw)) + + cal, err := ical.NewDecoder(bytes.NewReader(normalized)).Decode() + if err != nil { + t.Fatalf("decoding normalized calendar: %v", err) + } + events := cal.Events() + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + if _, err := events[0].RecurrenceSet(nil); err != nil { + t.Fatalf("expanding recurrence after normalization: %v", err) + } + + // The un-normalized original must reproduce the reported failure, so + // this test actually demonstrates the fix rather than trivially + // passing regardless. + origCal, err := ical.NewDecoder(bytes.NewReader([]byte(raw))).Decode() + if err != nil { + t.Fatalf("decoding original calendar: %v", err) + } + if _, err := origCal.Events()[0].RecurrenceSet(nil); err == nil { + t.Fatalf("expected RecurrenceSet to fail on un-normalized Windows TZID, it didn't") + } +} + +func TestNormalizeTimeZonesLeavesUnknownAndIANAZonesAlone(t *testing.T) { + const raw = "BEGIN:VCALENDAR\r\n" + + "DTSTART;TZID=Europe/Berlin:20260522T220000\r\n" + + "DTEND;TZID=Some/Unknown-Zone:20260522T230000\r\n" + + "END:VCALENDAR\r\n" + + got := string(NormalizeTimeZones([]byte(raw))) + if got != raw { + t.Fatalf("expected data to be unchanged, got:\n%s", got) + } +} diff --git a/internal/icssub/icssub.go b/internal/icssub/icssub.go index ae91293..5eb7610 100644 --- a/internal/icssub/icssub.go +++ b/internal/icssub/icssub.go @@ -5,6 +5,7 @@ package icssub import ( + "bytes" "context" "fmt" "io" @@ -14,6 +15,8 @@ import ( "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 @@ -103,7 +106,14 @@ func (c *Cache) fetch(rawURL string) (*ical.Calendar, error) { return nil, fmt.Errorf("fetching calendar: unexpected status %s", resp.Status) } - cal, err := ical.NewDecoder(io.LimitReader(resp.Body, maxBodySize)).Decode() + 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) } diff --git a/internal/web/calendar.go b/internal/web/calendar.go index 4b387f3..a5f90f6 100644 --- a/internal/web/calendar.go +++ b/internal/web/calendar.go @@ -18,6 +18,7 @@ import ( ical "github.com/emersion/go-ical" "github.com/yourusername/caldav-server/internal/birthdays" "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/icalfix" "github.com/yourusername/caldav-server/internal/web/templates" ) @@ -675,7 +676,7 @@ func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, owner http.NotFound(w, r) return } - existing, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() + existing, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode() if err != nil { s.logger.Error("decoding existing event", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -791,7 +792,7 @@ func (s *Server) saveEvent(owner, cal, id string, calendar *ical.Calendar) error // EventFormData shape used both for pre-filling the edit form and for // placing the event on the month grid. func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) { - calendar, err := ical.NewDecoder(strings.NewReader(string(data))).Decode() + calendar, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode() if err != nil { return templates.EventFormData{}, err } @@ -974,7 +975,7 @@ func (s *Server) handleCalendarImport(w http.ResponseWriter, r *http.Request) { } imported := 0 - dec := ical.NewDecoder(bytes.NewReader(body)) + dec := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(body))) for { srcCal, err := dec.Decode() if errors.Is(err, io.EOF) {