package main import ( "bytes" "encoding/xml" "errors" "fmt" "io" "log" "net/http" "net/url" "os" "path" "strings" "sync" "time" ) // The calendar sync mirrors every calendar of a CalDAV calendar home (e.g. // Nextcloud's /remote.php/dav/calendars//) into calendarsDir as // .ics. It runs in the background only: page renders read the // local files and never touch the network. A failed run leaves the previous // files in place. // calSyncMaxBody bounds a single calendar download. const calSyncMaxBody = 64 << 20 var calSyncClient = &http.Client{Timeout: 60 * time.Second} // calSyncResult is the outcome of the last run for one calendar. type calSyncResult struct { Updated time.Time // last successful download Error string } var calSync struct { run sync.Mutex // serializes runs mu sync.Mutex // guards the fields below lastRun time.Time lastError string // config/discovery error of the last run results map[string]calSyncResult } // startCalendarSync checks the settings once a minute and syncs when the // interval since the last run has passed. The first check happens right // away. Settings are read every time, so enabling the sync or changing the // interval needs no restart. func startCalendarSync() { go func() { for { s := readCalendarSettings(calendarsDir) if s.CaldavURL != "" { calSync.mu.Lock() last := calSync.lastRun calSync.mu.Unlock() if last.IsZero() || (s.SyncInterval > 0 && time.Since(last) >= s.SyncInterval) { runCalendarSync() } } time.Sleep(time.Minute) } }() } // runCalendarSync performs one sync run and records the outcome. func runCalendarSync() { calSync.run.Lock() defer calSync.run.Unlock() results, err := syncCalendars(readCalendarSettings(calendarsDir)) calSync.mu.Lock() defer calSync.mu.Unlock() calSync.lastRun = time.Now() calSync.lastError = "" if err != nil { calSync.lastError = err.Error() log.Printf("calendar sync: %v", err) } if calSync.results == nil { calSync.results = map[string]calSyncResult{} } for name, r := range results { prev := calSync.results[name] if r.Updated.IsZero() { r.Updated = prev.Updated } calSync.results[name] = r if r.Error != "" { log.Printf("calendar sync %s: %s", name, r.Error) } } } // calSyncSnapshot returns a copy of the sync status for display. func calSyncSnapshot() (lastRun time.Time, lastError string, results map[string]calSyncResult) { calSync.mu.Lock() defer calSync.mu.Unlock() results = make(map[string]calSyncResult, len(calSync.results)) for k, v := range calSync.results { results[k] = v } return calSync.lastRun, calSync.lastError, results } // handleCalendarSync runs a sync now (POST ?calsync on the calendars page) // and returns to the page. func (h *handler) handleCalendarSync(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { if calendarsDir == "" || fsPath != calendarsDir { http.Error(w, "not the calendars folder", http.StatusBadRequest) return } if readCalendarSettings(calendarsDir).CaldavURL == "" { http.Error(w, "sync is not configured (caldav-url)", http.StatusBadRequest) return } runCalendarSync() http.Redirect(w, r, urlPath, http.StatusSeeOther) } // syncCalendars downloads every calendar of the calendar home. The returned // map has one entry per calendar that was attempted; err is set when nothing // could be attempted (bad settings, discovery failed). func syncCalendars(s calendarSettings) (map[string]calSyncResult, error) { home, err := url.Parse(s.CaldavURL) if err != nil || (home.Scheme != "http" && home.Scheme != "https") { return nil, fmt.Errorf("invalid caldav-url %q", s.CaldavURL) } if !strings.HasSuffix(home.Path, "/") { home.Path += "/" } cals, err := davDiscoverCalendars(home, s) if err != nil { return nil, err } results := map[string]calSyncResult{} used := map[string]bool{} for _, c := range cals { name := calendarFileName(c.name, c.href) if used[strings.ToLower(name)] { name += " (" + path.Base(strings.TrimSuffix(c.href, "/")) + ")" } used[strings.ToLower(name)] = true if err := syncOneCalendar(home, c.href, name, s); err != nil { results[name] = calSyncResult{Error: err.Error()} continue } results[name] = calSyncResult{Updated: time.Now()} } return results, nil } // syncOneCalendar downloads one calendar via Nextcloud's ?export and writes // it when the content changed. Invalid downloads (HTML error pages, broken // iCalendar) never replace the existing file. func syncOneCalendar(home *url.URL, href, name string, s calendarSettings) error { u, err := home.Parse(href) if err != nil { return err } u.RawQuery = "export" req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return err } req.SetBasicAuth(s.CaldavUser, s.CaldavPass) req.Header.Set("Accept", "text/calendar") resp, err := calSyncClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download: %s", resp.Status) } data, err := io.ReadAll(io.LimitReader(resp.Body, calSyncMaxBody+1)) if err != nil { return err } if len(data) > calSyncMaxBody { return errors.New("download: calendar larger than 64 MB") } if _, err := parseCalendar(name+".ics", data); err != nil { return fmt.Errorf("invalid calendar data: %v", err) } p := calendarPath(calendarsDir, name) if p == "" { return fmt.Errorf("unusable file name %q", name) } if old, err := os.ReadFile(p); err == nil && bytes.Equal(old, data) { return nil } if err := os.MkdirAll(calendarsDir, 0755); err != nil { return err } return writeFileAtomic(p, data, 0644) } // calendarFileName turns a display name into a safe file basename. Falls back // to the last segment of the calendar's href. func calendarFileName(display, href string) string { clean := strings.Map(func(r rune) rune { if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) { return '_' } return r }, display) clean = strings.TrimLeft(strings.TrimSpace(clean), ".") if clean == "" { clean = strings.TrimLeft(path.Base(strings.TrimSuffix(href, "/")), ".") } if clean == "" { clean = "calendar" } return clean } // === Discovery === type davCalendar struct { href string name string } const davPropfindBody = ` ` type davMultistatus struct { Responses []struct { Href string `xml:"DAV: href"` Propstats []struct { Status string `xml:"DAV: status"` Prop struct { DisplayName string `xml:"DAV: displayname"` ResourceType struct { Types []struct { XMLName xml.Name } `xml:",any"` } `xml:"DAV: resourcetype"` } `xml:"DAV: prop"` } `xml:"DAV: propstat"` } `xml:"DAV: response"` } // davDiscoverCalendars lists the calendar collections directly below home // with one PROPFIND (Depth 1). func davDiscoverCalendars(home *url.URL, s calendarSettings) ([]davCalendar, error) { req, err := http.NewRequest("PROPFIND", home.String(), strings.NewReader(davPropfindBody)) if err != nil { return nil, err } req.SetBasicAuth(s.CaldavUser, s.CaldavPass) req.Header.Set("Depth", "1") req.Header.Set("Content-Type", "application/xml; charset=utf-8") resp, err := calSyncClient.Do(req) if err != nil { return nil, fmt.Errorf("discovery: %v", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { return nil, errors.New("discovery: authentication failed (check caldav-user / caldav-pass)") } if resp.StatusCode != http.StatusMultiStatus { return nil, fmt.Errorf("discovery: %s", resp.Status) } var ms davMultistatus if err := xml.NewDecoder(io.LimitReader(resp.Body, calSyncMaxBody)).Decode(&ms); err != nil { return nil, fmt.Errorf("discovery: %v", err) } var out []davCalendar for _, r := range ms.Responses { for _, ps := range r.Propstats { if !strings.Contains(ps.Status, " 200 ") { continue } isCalendar := false for _, t := range ps.Prop.ResourceType.Types { if t.XMLName.Space == "urn:ietf:params:xml:ns:caldav" && t.XMLName.Local == "calendar" { isCalendar = true } } if isCalendar { out = append(out, davCalendar{href: r.Href, name: strings.TrimSpace(ps.Prop.DisplayName)}) } } } if len(out) == 0 { return nil, errors.New("discovery: no calendars found at caldav-url") } return out, nil }