hivedav

A curlable free/busy scheduler with CalDAV integration
git clone https://git.in0rdr.ch/hivedav.git
Log | Files | Refs | Pull requests | README | LICENSE

commit b7e9941ecde84a7ef0e2142d0a34c49f5e821f4d
parent 3de55c280733b19053918faf05a975a6e7b48599
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Thu, 13 Jun 2024 21:07:47 +0200

feat(discovery): add HIVEDAV_CALDAV_HOST

Diffstat:
MREADME.md | 11+++++++++++
Mapp.env.example | 4++++
Mcaldav/caldav.go | 31++++++++++++++++++++-----------
Mconfig/config.go | 4++++
Mmain.go | 66+++++++++++++++++++++++++++++++++++++++++-------------------------
Mserver.go | 12++++++++++--
6 files changed, 90 insertions(+), 38 deletions(-)

diff --git a/README.md b/README.md @@ -49,6 +49,7 @@ The application is configurable through environment variables or the # Optional #HIVEDAV_LISTEN_ADDRESS=127.0.0.1 #HIVEDAV_LISTEN_PORT=3737 +# only useful for discovery of the calendar when HIVEDAV_CALDAV_HOST is set #HIVEDAV_CALENDAR=0 #HIVEDAV_HORIZON=1 #HIVEDAV_BOOKING_SUBJ="HiveDAV Booking" @@ -58,7 +59,10 @@ The application is configurable through environment variables or the # Required HIVEDAV_HOST=hivedav.example.com +# an absolute uri to a caldav calendar, mutually exclusive with HIVEDAV_CALDAV_HOST HIVEDAV_CALDAV_URI= +# the host for caldav discovery might have a port, mutually exclusive with HIVEDAV_CALDAV_URI +HIVEDAV_CALDAV_HOST= HIVEDAV_CALDAV_USER= # make sure to quote in single quotes to properly esacpe special chars HIVEDAV_CALDAV_PASSWORD='' @@ -71,6 +75,11 @@ HIVEDAV_SMTP_PASSWORD='' There is an example config file provided in `./app.env.example`. +* `HIVEDAV_CALDAV_URI` is an absolute uri to a calendar resource on a caldav + server. No discovery is performed. You either have to define + `HIVEDAV_CALDAV_URI` or `HIVEDAV_CALDAV_HOST`. +* With `HIVEDAV_CALDAV_HOST`, HiveDAV performs a discovery of the calendar uri + using `HIVEDAV_CALENDAR`. * The `HIVEDAV_LISTEN_*` variables specify the http server `ADDRESS` and `PORT` (bind address and port) * The `HIVEDAV_CALENDAR` is a number that points to the index of the calendar @@ -148,6 +157,8 @@ application work with as many remote servers as possible, all events from the remote calendar are fetched during an update and the availability data is stored in a local database for further processing. +For more information refer to [`./docs/CALDAV.md`](./docs/CALDAV.md) + ## Availability Query - s: start time diff --git a/app.env.example b/app.env.example @@ -1,7 +1,11 @@ #HIVEDAV_LISTEN_ADDRESS=127.0.0.1 #HIVEDAV_LISTEN_PORT=3737 #HIVEDAV_HOST=hivedav.example.com +# an absolute uri to a caldav calendar, mutually exclusive with HIVEDAV_CALDAV_HOST #HIVEDAV_CALDAV_URI= +# the host for caldav discovery might have a port, mutually exclusive with HIVEDAV_CALDAV_URI +#HIVEDAV_CALDAV_HOST= +# only useful for discovery of the calendar when HIVEDAV_CALDAV_HOST is set #HIVEDAV_CALENDAR=0 #HIVEDAV_HORIZON=1 #HIVEDAV_CALDAV_USER= diff --git a/caldav/caldav.go b/caldav/caldav.go @@ -36,18 +36,19 @@ type CalData struct { Data string `xml:"propstat>prop>calendar-data"` } -func UserPrincipal(caldavUri string, user string, pass string) (string, error) { +func UserPrincipal(caldavHost string, user string, pass string) (string, error) { payload := []byte(`<d:propfind xmlns:d="DAV:"> <d:prop> <d:current-user-principal /> </d:prop> </d:propfind>`) reader := bytes.NewReader(payload) - req, err := http.NewRequest("PROPFIND", caldavUri, reader) + req, err := http.NewRequest("PROPFIND", caldavHost, reader) if err != nil { return "", err } req.Header.Set("Depth", "0") + req.Header.Set("Content-Type", "text/xml") req.SetBasicAuth(user, pass) res, err := http.DefaultClient.Do(req) @@ -69,20 +70,21 @@ func UserPrincipal(caldavUri string, user string, pass string) (string, error) { return xmlResponse.UserPrincipal, nil } -func CalendarHome(caldavUri string, user string, pass string, userPrincipal string, calendar int) (string, error) { +func CalendarHome(caldavHost string, user string, pass string, userPrincipal string, calendar int) (string, error) { payload := []byte(`<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> <d:prop> <c:calendar-home-set /> </d:prop> </d:propfind>`) reader := bytes.NewReader(payload) - principalUri := fmt.Sprintf("%s%s", caldavUri, userPrincipal) + principalPath := fmt.Sprintf("%s%s", caldavHost, userPrincipal) - req, err := http.NewRequest("PROPFIND", principalUri, reader) + req, err := http.NewRequest("PROPFIND", principalPath, reader) if err != nil { return "", err } req.Header.Set("Depth", "0") + req.Header.Set("Content-Type", "text/xml") req.SetBasicAuth(user, pass) res, err := http.DefaultClient.Do(req) @@ -104,7 +106,7 @@ func CalendarHome(caldavUri string, user string, pass string, userPrincipal stri return xmlResponse.HomeSet, nil } -func CalendarFromHomeSet(caldavUri string, user string, pass string, homeSetUri string, calendar int) (calendarUri string, err error) { +func CalendarFromHomeSet(caldavHost string, user string, pass string, homeSetPath string, calendar int) (calendarPath string, err error) { payload := []byte(`<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav"> <d:prop> <d:resourcetype /> @@ -114,13 +116,14 @@ func CalendarFromHomeSet(caldavUri string, user string, pass string, homeSetUri </d:prop> </d:propfind>`) reader := bytes.NewReader(payload) - homeSetUrl := fmt.Sprintf("%s%s", caldavUri, homeSetUri) + homeSetUrl := fmt.Sprintf("%s%s", caldavHost, homeSetPath) req, err := http.NewRequest("PROPFIND", homeSetUrl, reader) if err != nil { return "", err } req.Header.Set("Depth", "1") + req.Header.Set("Content-Type", "text/xml") req.SetBasicAuth(user, pass) res, err := http.DefaultClient.Do(req) @@ -145,11 +148,17 @@ func CalendarFromHomeSet(caldavUri string, user string, pass string, homeSetUri } }() - calendarUri = xmlResponse.Response[calendar].Href - return calendarUri, nil + calendarPath = xmlResponse.Response[calendar].Href + return calendarPath, nil } -func GetAvailability(caldavUri string, user string, pass string, calendarUri string) (data []CalData, err error) { +func GetAvailability(caldavHost string, user string, pass string, calendarPath string) (data []CalData, err error) { + // concatenant the host and the calendar path + calendarUrl := fmt.Sprintf("%s%s", caldavHost, calendarPath) + return GetAvailabilityByUrl(calendarUrl, user, pass) +} + +func GetAvailabilityByUrl(calendarUrl string, user string, pass string) (data []CalData, err error) { payload := []byte(`<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> <d:prop> <d:getetag /> @@ -162,13 +171,13 @@ func GetAvailability(caldavUri string, user string, pass string, calendarUri str </c:filter> </c:calendar-query>`) reader := bytes.NewReader(payload) - calendarUrl := fmt.Sprintf("%s%s", caldavUri, calendarUri) req, err := http.NewRequest("REPORT", calendarUrl, reader) if err != nil { return data, err } req.Header.Set("Depth", "1") + req.Header.Set("Content-Type", "text/xml") req.SetBasicAuth(user, pass) res, err := http.DefaultClient.Do(req) diff --git a/config/config.go b/config/config.go @@ -10,6 +10,7 @@ type Config struct { ListenAddress string `mapstructure:"HIVEDAV_LISTEN_ADDRESS"` ListenPort int `mapstructure:"HIVEDAV_LISTEN_PORT"` CaldavUri string `mapstructure:"HIVEDAV_CALDAV_URI"` + CaldavHost string `mapstructure:"HIVEDAV_CALDAV_HOST"` Calendar int `mapstructure:"HIVEDAV_CALENDAR"` Horizon int `mapstructure:"HIVEDAV_HORIZON"` CaldavUser string `mapstructure:"HIVEDAV_CALDAV_USER"` @@ -74,6 +75,9 @@ func (c *Config) LoadConfig(path string) (*Config, error) { if viper.IsSet("CALDAV_URI") { c.CaldavUri = viper.GetString("CALDAV_URI") } + if viper.IsSet("CALDAV_HOST") { + c.CaldavHost = viper.GetString("CALDAV_HOST") + } if viper.IsSet("CALENDAR") { c.Calendar = viper.GetInt("CALENDAR") } diff --git a/main.go b/main.go @@ -29,10 +29,11 @@ func main() { log.Printf("HiveDAV %s, %s 🍯\n", hivedavVersion, conf.HiveDavHost) log.Println("----------------------------------------------------") - log.Printf("Cron interval:\t%d minutes\n", conf.RefreshInterval) - log.Printf("Calendar horizon:\t%d year(s)\n", conf.Horizon) - log.Printf("CalDAV server:\t%s\n", conf.CaldavUri) - log.Printf("CalDAV user:\t%s\n", conf.CaldavUser) + log.Printf("Cron interval:\t\t%d minutes\n", conf.RefreshInterval) + log.Printf("Calendar horizon:\t\t%d year(s)\n", conf.Horizon) + log.Printf("CalDAV uri:\t\t\t%s\n", conf.CaldavUri) + log.Printf("CalDAV discovery host:\t%s\n", conf.CaldavHost) + log.Printf("CalDAV user:\t\t%s\n", conf.CaldavUser) c := cron.New() c.AddFunc(fmt.Sprintf("@every %dm", conf.RefreshInterval), func() { updateDatabase(server, c) }) @@ -48,36 +49,51 @@ func main() { router.GET("/book/:dtstart", server.CubicleForm) router.ServeFiles("/css/*filepath", http.Dir("./css")) router.GET("/healthz", server.Healthz) - log.Printf("Listening:\t\t%s:%d\n", conf.ListenAddress, conf.ListenPort) + log.Printf("Listening:\t\t\t%s:%d\n", conf.ListenAddress, conf.ListenPort) log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", conf.ListenAddress, conf.ListenPort), router)) } func updateDatabase(s *Server, c *cron.Cron) { - // Find current user principal - userPrincipal, err := caldav.UserPrincipal(s.config.CaldavUri, s.config.CaldavUser, s.config.CaldavPassword) - if err != nil { - log.Fatal("Error reading current user principal: ", err) - } - log.Printf("User principal:\t%s\n", userPrincipal) + var calData []caldav.CalData + var calendarPath string + var err error + // discover calendar based on HIVEDAV_CALENDAR when no CALDAV_URI is set + if s.config.CaldavUri == "" { + log.Println("----------------------------------------------------") + log.Printf("Discovering calendars on host %s...\n", s.config.CaldavHost) + // Find current user principal + userPrincipal, err := caldav.UserPrincipal(s.config.CaldavHost, s.config.CaldavUser, s.config.CaldavPassword) + if err != nil { + log.Fatal("Error reading current user principal: ", err) + } + log.Printf("User principal:\t\t%s\n", userPrincipal) - // Find home set of current user principal - homeSetUri, err := caldav.CalendarHome(s.config.CaldavUri, s.config.CaldavUser, s.config.CaldavPassword, userPrincipal, s.config.Calendar) - if err != nil { - log.Fatal("Error reading home set URI: ", err) - } - log.Printf("Calendar homeset:\t%s", homeSetUri) + // Find home set of current user principal + homeSetPath, err := caldav.CalendarHome(s.config.CaldavHost, s.config.CaldavUser, s.config.CaldavPassword, userPrincipal, s.config.Calendar) + if err != nil { + log.Fatal("Error reading home set URI: ", err) + } + log.Printf("Calendar homeset:\t\t%s", homeSetPath) - // Find calendar URI for the calendar specified in the config - calendarUri, err := caldav.CalendarFromHomeSet(s.config.CaldavUri, s.config.CaldavUser, s.config.CaldavPassword, homeSetUri, s.config.Calendar) - if err != nil { - log.Fatal("Error reading calendar URI: ", err) + // Find calendar URI for the calendar specified in the config + calendarPath, err = caldav.CalendarFromHomeSet(s.config.CaldavHost, s.config.CaldavUser, s.config.CaldavPassword, homeSetPath, s.config.Calendar) + if err != nil { + log.Fatal("Error reading calendar URI: ", err) + } + log.Printf("Calendar %d path:\t\t%s\n", s.config.Calendar, calendarPath) } - log.Printf("Calendar %d:\t\t%s\n", s.config.Calendar, calendarUri) + log.Println("----------------------------------------------------") log.Println("Fetching availability from CalDAV server, please wait..") - // Get availability data for the calendar specified in the config - calData, err := caldav.GetAvailability(s.config.CaldavUri, s.config.CaldavUser, s.config.CaldavPassword, calendarUri) + if s.config.CaldavUri == "" { + // Get availability data for the calendar specified in the config + calData, err = caldav.GetAvailability(s.config.CaldavHost, s.config.CaldavUser, s.config.CaldavPassword, calendarPath) + } else { + // don't perform discovery, use the CALDAV_URI as calendar url + calData, err = caldav.GetAvailabilityByUrl(s.config.CaldavUri, s.config.CaldavUser, s.config.CaldavPassword) + } + if err != nil { log.Fatal("Error reading calendar URI: ", err) } @@ -88,6 +104,6 @@ func updateDatabase(s *Server, c *cron.Cron) { } log.Println("Database initialized") - log.Printf("Next db refresh (cron): %v\n", c.Entries()[0].Schedule.Next(time.Now()).Format("2006-01-02 15:04")) + log.Printf("Next db refresh (cron): \t%v\n", c.Entries()[0].Schedule.Next(time.Now()).Format("2006-01-02 15:04")) log.Println("----------------------------------------------------") } diff --git a/server.go b/server.go @@ -401,8 +401,16 @@ func (s *Server) UpdateAvailability(calData []caldav.CalData) error { } for _, e := range cal.Events() { - start := e.Props.Get(ical.PropDateTimeStart).Value - end := e.Props.Get(ical.PropDateTimeEnd).Value + startTimeProp := e.Props.Get(ical.PropDateTimeStart) + endTimeProp := e.Props.Get(ical.PropDateTimeEnd) + + if startTimeProp == nil || endTimeProp == nil { + // skip events with funny format, not useful anyways + continue + } + + start := startTimeProp.Value + end := endTimeProp.Value status := e.Props.Get("X-MICROSOFT-CDO-INTENDEDSTATUS") statusValue := "BUSY"