Add Calendar integration (read-only)
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
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/<user>/) into calendarsDir as
|
||||
// <display name>.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 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>`
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user