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 1d83b87c7c96431236d6c51ede2908dd0e30030b
parent 93f3f7ee6b4259c3b42b6cf698ecf5b15da97f17
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Wed, 27 Sep 2023 01:42:38 +0200

feat: send ics invite

Diffstat:
MMakefile | 2+-
MREADME.md | 6++++++
Mapp.env.example | 5++++-
Mconfig/config.go | 30+++++++++++++++++++++++++++++-
Mgo.mod | 8++++----
Mgo.sum | 5++---
Mmain.go | 27++++++++++++++-------------
Mserver.go | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Atemplates/booking.ics | 40++++++++++++++++++++++++++++++++++++++++
Mtemplates/index.html | 3+++
Atemplates/listcubicles.html | 28++++++++++++++++++++++++++++
11 files changed, 337 insertions(+), 108 deletions(-)

diff --git a/Makefile b/Makefile @@ -16,7 +16,7 @@ clean: .PHONY: fmt fmt: - $(GOFMT) -w ***/*.go + $(GOFMT) -w -s . .PHONY: test test: diff --git a/README.md b/README.md @@ -50,6 +50,8 @@ The application is configurable through environment variables or the #HIVEDAV_LISTEN_ADDRESS=127.0.0.1 #HIVEDAV_LISTEN_PORT=3737 #HIVEDAV_CALENDAR=0 +#HIVEDAV_BOOKING_SUBJ="HiveDAV Booking" +#HIVEDAV_BOOKING_REMINDER=15 # Required HIVEDAV_HOST=hivedav.example.com @@ -70,6 +72,10 @@ There is an example config file provided in `./app.env.example`. collection in the ["calendar home set"](https://www.rfc-editor.org/rfc/rfc4791.html#section-6.2.1) which should be used to read availability data +* `HIVEDAV_BOOKING_SUBJ` is the subject of the calendar invite for new booking + requests +* `HIVEDAV_BOOKING_REMINDER` is the reminder time in minutes before the start + of the calendar appointment * `HIVEDAV_HOST` is the fqdn of the HiveDAV service. It is used to print the help commands in the curlable interface. * `HIVEDAV_CALDAV_*` are settings to connect to the CalDAV server for fetching diff --git a/app.env.example b/app.env.example @@ -6,6 +6,9 @@ #HIVEDAV_CALDAV_USER= #HIVEDAV_CALDAV_PASSWORD= #HIVEDAV_REFRESH_INTERVAL=30 -#HIVEDAV_SMTP_SERVER= +#HIVEDAV_SMTP_HOST= +#HIVEDAV_SMTP_PORT=587 #HIVEDAV_SMTP_USER= #HIVEDAV_SMTP_PASSWORD= +#HIVEDAV_BOOKING_SUBJ="HiveDAV Booking" +#HIVEDAV_BOOKING_REMINDER=15 diff --git a/config/config.go b/config/config.go @@ -14,9 +14,13 @@ type Config struct { CaldavUser string `mapstructure:"HIVEDAV_CALDAV_USER"` CaldavPassword string `mapstructure:"HIVEDAV_CALDAV_PASSWORD"` RefreshInterval int `mapstructure:"HIVEDAV_REFRESH_INTERVAL"` - SmtpServer string `mapstructure:"HIVEDAV_SMTP_SERVER"` + SmtpHost string `mapstructure:"HIVEDAV_SMTP_HOST"` + SmtpPort int `mapstructure:"HIVEDAV_SMTP_PORT"` SmtpUser string `mapstructure:"HIVEDAV_SMTP_USER"` SmtpPassword string `mapstructure:"HIVEDAV_SMTP_PASSWORD"` + BookingSummary string `mapstructure:"HIVEDAV_BOOKING_SUMMARY"` + BookingLocation string `mapstructure:"HIVEDAV_BOOKING_LOCATION"` + BookingReminder int `mapstructure:"HIVEDAV_BOOKING_REMINDER"` } func (c *Config) LoadConfig(path string) (*Config, error) { @@ -32,6 +36,9 @@ func (c *Config) LoadConfig(path string) (*Config, error) { viper.SetDefault("HIVEDAV_LISTEN_ADDRESS", "[::]") viper.SetDefault("HIVEDAV_CALENDAR", 0) viper.SetDefault("HIVEDAV_REFRESH_INTERVAL", 30) + viper.SetDefault("HIVEDAV_BOOKING_SUMMARY", "HiveDAV Booking") + viper.SetDefault("HIVEDAV_BOOKING_LOCATION", "https://meet.jit.si") + viper.SetDefault("HIVEDAV_BOOKING_REMINDER", 15) err := viper.ReadInConfig() if err != nil { @@ -74,6 +81,27 @@ func (c *Config) LoadConfig(path string) (*Config, error) { if viper.IsSet("REFRESH_INTERVAL") { c.RefreshInterval = viper.GetInt("REFRESH_INTERVAL") } + if viper.IsSet("HIVEDAV_SMTP_HOST") { + c.SmtpHost = viper.GetString("HIVEDAV_SMTP_HOST") + } + if viper.IsSet("HIVEDAV_SMTP_PORT") { + c.SmtpPort = viper.GetInt("HIVEDAV_SMTP_PORT") + } + if viper.IsSet("HIVEDAV_SMTP_USER") { + c.SmtpUser = viper.GetString("HIVEDAV_SMTP_USER") + } + if viper.IsSet("HIVEDAV_SMTP_PASSWORD") { + c.SmtpPassword = viper.GetString("HIVEDAV_SMTP_PASSWORD") + } + if viper.IsSet("HIVEDAV_BOOKING_SUMMARY") { + c.BookingSummary = viper.GetString("HIVEDAV_BOOKING_SUMMARY") + } + if viper.IsSet("HIVEDAV_BOOKING_LOCATION") { + c.BookingLocation = viper.GetString("HIVEDAV_BOOKING_LOCATION") + } + if viper.IsSet("HIVEDAV_BOOKING_REMINDER") { + c.BookingReminder = viper.GetInt("HIVEDAV_BOOKING_REMINDER") + } return c, nil } diff --git a/go.mod b/go.mod @@ -4,16 +4,18 @@ go 1.21.0 require ( github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f - github.com/in0rdr/go-webdav v0.0.0-20230903141941-cde95745290e + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible + github.com/julienschmidt/httprouter v1.3.0 github.com/mattn/go-sqlite3 v1.14.17 github.com/pquerna/termchalk v0.0.0-20140809212720-8cc5932700ba github.com/spf13/viper v1.16.0 + github.com/teambition/rrule-go v1.8.2 + gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5 ) require ( github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -24,11 +26,9 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect - github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/robfig/cron.v2 v2.0.0-20150107220207-be2e0b0deed5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum @@ -127,8 +127,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/in0rdr/go-webdav v0.0.0-20230903141941-cde95745290e h1:iXhjrrNT+r6E6HrXsUfFcYGWkG3rnOdqPHDxcvbt/9g= -github.com/in0rdr/go-webdav v0.0.0-20230903141941-cde95745290e/go.mod h1:u8ZQQE8aW8NvpP101ttUsXcbtRKiedaHAPtsKAFpPnY= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= @@ -185,7 +185,6 @@ github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gt github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= -github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0= github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= diff --git a/main.go b/main.go @@ -27,10 +27,11 @@ func main() { log.Fatal("Error creating server with config: ", err) } - log.Printf("Starting hivedav %s, %s 🍯\n", hivedavVersion, conf.HiveDavHost) - log.Printf("Cron availability refresh interval is %d minutes ⏰\n", conf.RefreshInterval) - log.Println("Reading availability from:", conf.CaldavUri) - log.Println("Reading as user:", conf.CaldavUser) + log.Printf("HiveDAV %s, %s 🍯\n", hivedavVersion, conf.HiveDavHost) + log.Println("----------------------------------------------------") + log.Printf("Cron interval:\t%d minutes\n", conf.RefreshInterval) + log.Printf("CalDAV server:\t%s\n", conf.CaldavUri) + log.Printf("CalDAV user:\t%s\n", conf.CaldavUser) c := cron.New() c.AddFunc(fmt.Sprintf("@every %dm", conf.RefreshInterval), func() { updateDatabase(server, c) }) @@ -45,34 +46,33 @@ func main() { router.POST("/book/:dtstart", server.BookCubicle) router.ServeFiles("/css/*filepath", http.Dir("./css")) router.GET("/healthz", server.Healthz) - log.Printf("Listening on %s:%d\n", conf.ListenAddress, conf.ListenPort) + log.Printf("Listening:\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) { - log.Println("Starting availability update 🔄") - // 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.Println("User principal:", userPrincipal) + log.Printf("User principal:\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.Println("Calendar homeset:", homeSetUri) + log.Printf("Calendar homeset:\t%s", homeSetUri) // 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) } - log.Printf("Using calendar %d in homeset with URI '%s'\n", s.config.Calendar, calendarUri) - log.Println("Fetching availability from CalDAV server, please wait.. ⏳") + 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) @@ -85,6 +85,7 @@ func updateDatabase(s *Server, c *cron.Cron) { log.Fatalf("Error fetching availability: %v", err) } - log.Println("Ready. Database was initialized with latest CalDAV availability data.") - log.Printf("Next automatic refresh of the db (cron): %v\n", c.Entries()[0].Schedule.Next(time.Now()).Format("2006-01-02 15:04")) + 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.Println("----------------------------------------------------") } diff --git a/server.go b/server.go @@ -1,9 +1,13 @@ package main import ( + "crypto/rand" + "crypto/tls" "database/sql" + "encoding/hex" "fmt" "github.com/emersion/go-ical" + "github.com/jordan-wright/email" "github.com/julienschmidt/httprouter" _ "github.com/mattn/go-sqlite3" "github.com/pquerna/termchalk/prettytable" @@ -14,12 +18,13 @@ import ( "io" "log" "net/http" + netmail "net/mail" + "net/smtp" + "os" "regexp" "strconv" "strings" "time" - "net/smtp" - "bytes" ) type Server struct { @@ -28,11 +33,31 @@ type Server struct { } type TableData struct { - TableHead []string - Rows [][]string - Week int - Year int - Version string + TableHead []string + Rows [][]string + Week int + Year int + Version string + HiveDavHost string +} + +type IcsData struct { + Timestamp string + Prodid string + Uid string + Dtstart string + Dtend string + Timezone string + StandardTzOffsetFrom string + StandardTzOffsetTo string + DstTzOffsetFrom string + DstTzOffsetTo string + Summary string + Reminder int + Description string + Location string + Organizer string + Attendee string } var drop = `DROP TABLE IF EXISTS availability; @@ -141,12 +166,6 @@ func (s *Server) available(start string, end string) (bool, error) { } func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - // TODO: make display timezone configurable, display in local time - localLocation, err := time.LoadLocation("Local") - if err != nil { - return - } - userAgent := req.Header.Get("user-agent") timeNow := time.Now() @@ -154,11 +173,11 @@ func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Pa year, week := timeNow.ISOWeek() // overwrite with requested week - week, err = strconv.Atoi(ps.ByName("week")) + week, err := strconv.Atoi(ps.ByName("week")) if err != nil { - log.Printf("Cannot serve requested week '%d'\n", week) w.WriteHeader(http.StatusBadRequest) - // TODO: Print human readable error to w + log.Printf("Cannot serve requested week '%d'\n", week) + fmt.Fprintf(w, "Cannot serve requested week '%d'\n", week) return } @@ -186,16 +205,18 @@ func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Pa } tableData := TableData{ - TableHead: tableHead, - Rows: rows, - Week: week, - Year: year, - Version: hivedavVersion, + TableHead: tableHead, + Rows: rows, + Week: week, + Year: year, + Version: hivedavVersion, + HiveDavHost: s.config.HiveDavHost, } // TODO: use timeIt to go through the loops below, remove the static arrays - timeIt := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, localLocation) - //dayEnd := time.Date(monday.Year(), monday.Month(), monday.Day(), 17, 0, 0, 0, localLocation) + // TODO: make display timezone configurable, display in local time + timeIt := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, time.Local) + //dayEnd := time.Date(monday.Year(), monday.Month(), monday.Day(), 17, 0, 0, 0, time.Local) //weekEnd := dayEnd.Add(time.Hour * 24*5) // Working hours - Eight to Five @@ -258,14 +279,15 @@ func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Pa if err != nil { log.Printf("Error parsing html template: %v\n", err) w.WriteHeader(http.StatusInternalServerError) - // TODO: Print human readable error to w + log.Printf("Error parsing html template: %v\n", err) + fmt.Fprintf(w, "Error parsing html template: %v\n", err) return } err = tmpl.Execute(w, tableData) if err != nil { - log.Printf("Error executing html template: %v\n", err) w.WriteHeader(http.StatusInternalServerError) - // TODO: Print human readable error to w + log.Printf("Error executing html template: %v\n", err) + fmt.Fprintf(w, "Error executing html template: %v\n", err) return } } @@ -283,36 +305,31 @@ func (s *Server) Index(w http.ResponseWriter, req *http.Request, _ httprouter.Pa func (s *Server) UpdateAvailability(calData []caldav.CalData) error { // TODO: Make max for recurring events without end time configurable rrLimit := time.Date(2024, 12, 31, 0, 0, 0, 0, time.Local) - //localNow := time.Now() + localNow := time.Now() + isDSTNow := localNow.IsDST() // Walk through events and store start/end time in the database for _, event := range calData { dec := ical.NewDecoder(strings.NewReader(event.Data)) cal, err := dec.Decode() - var ( - //tzProp *ical.Prop - // offset which is in use prior to this time zone observance - //tzOffsetTo string - //tzOffsetFrom string - // offset which is in use in this time zone observance - tzOffsetTo string - // TODO:TZOFFSETFROM is the local time offset from GMT - // when daylight saving time is in operation, - // TZOFFSETTO is the local time offset from GMT when - // standard time is in operation - // https://stackoverflow.com/questions/3872178/what-does-tzoffsetfrom-and-tzoffsetto-mean - ) + var tzOffsetTo string if err != nil { return err } - // check for TIMEZONE component + // check for TIMEZONE component and read TZOFFSETTO from the + // correct sub-child for _, c := range cal.Children { if c.Name == ical.CompTimezone { for _, child := range c.Children { - //tzOffsetFrom = child.Props.Get(ical.PropTimezoneOffsetFrom).Value; - tzOffsetTo = child.Props.Get(ical.PropTimezoneOffsetTo).Value + if isDSTNow && child.Name == ical.CompTimezoneDaylight { + // summer time + tzOffsetTo = child.Props.Get(ical.PropTimezoneOffsetTo).Value + } else if !isDSTNow && child.Name == ical.CompTimezoneStandard { + // winter time + tzOffsetTo = child.Props.Get(ical.PropTimezoneOffsetTo).Value + } } } } @@ -452,12 +469,6 @@ func (s *Server) ListCubicles(w http.ResponseWriter, req *http.Request, _ httpro } func (s *Server) ListCubiclesInWeek(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - // TODO: make display timezone configurable, display in local time - localLocation, err := time.LoadLocation("Local") - if err != nil { - return - } - userAgent := req.Header.Get("user-agent") timeNow := time.Now() @@ -465,11 +476,11 @@ func (s *Server) ListCubiclesInWeek(w http.ResponseWriter, req *http.Request, ps year, week := timeNow.ISOWeek() // overwrite with requested week - week, err = strconv.Atoi(ps.ByName("week")) + week, err := strconv.Atoi(ps.ByName("week")) if err != nil { - log.Printf("Cannot serve requested week '%d'\n", week) w.WriteHeader(http.StatusBadRequest) - // TODO: Print human readable error to w + log.Printf("Cannot serve requested week '%d'\n", week) + fmt.Fprintf(w, "Cannot serve requested week '%d'\n", week) return } @@ -486,21 +497,21 @@ func (s *Server) ListCubiclesInWeek(w http.ResponseWriter, req *http.Request, ps // TODO: Make heading date format configurable, start of week, etc.. tableHead := []string{ "Date/Time ", - // TODO: %!v(PANIC=String method: strings: negative Repeat count) - // with too many spaces - "Book Cubicle ", + // TODO: %!v(PANIC=String method: strings: negative Repeat count) + "Book Cubicle ", } tableData := TableData{ - TableHead: tableHead, - Rows: rows, - Week: week, - Year: year, - Version: hivedavVersion, + TableHead: tableHead, + Rows: rows, + Week: week, + Year: year, + Version: hivedavVersion, + HiveDavHost: s.config.HiveDavHost, } // TODO: use timeIt to go through the loops below, remove the static arrays - timeIt := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, localLocation) + timeIt := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, time.Local) // 5 days * 9 1hour time slots = 45 possible booking commands for d := 0; d < 5; d++ { @@ -551,70 +562,180 @@ func (s *Server) ListCubiclesInWeek(w http.ResponseWriter, req *http.Request, ps tmpl, err := template.ParseFiles("./templates/listcubicles.html") if err != nil { - log.Printf("Error parsing html template: %v\n", err) w.WriteHeader(http.StatusInternalServerError) - // TODO: Print human readable error to w + log.Printf("Error parsing html template: %v\n", err) + fmt.Fprintf(w, "Error parsing html template: %v\n", err) return } err = tmpl.Execute(w, tableData) if err != nil { - log.Printf("Error executing html template: %v\n", err) w.WriteHeader(http.StatusInternalServerError) - // TODO: Print human readable error to w + log.Printf("Error executing html template: %v\n", err) + fmt.Fprintf(w, "Error executing html template: %v\n", err) return } } } func (s *Server) BookCubicle(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - //TODO: create and attach ical file dtstart := ps.ByName("dtstart") - //TODO: replace by config setting - dtstartTime, err := time.ParseInLocation("2006-01-02-15", dtstart, time.Local) + //TODO: replace Local TZ by config setting + //TODO: remove Year from the input format + dtStartTimeLocal, err := time.ParseInLocation("2006-01-02-15", dtstart, time.Local) if err != nil { - log.Println("Error parsing booking dtstart") + w.WriteHeader(http.StatusInternalServerError) + log.Printf("Error parsing booking dtstart: %v\n", err) + fmt.Fprintf(w, "Error parsing booking dtstart: %v\n", err) return } - log.Println("dtstart booking request: ", dtstartTime) + log.Printf("dtstart booking request (local time): %v\n", dtStartTimeLocal) // total of maxMemory bytes of its file parts are stored in memory // with the remainder stored on disk in temporary files // https://pkg.go.dev/net/http#Request.ParseMultipartForm err = req.ParseMultipartForm(256) if err != nil { - fmt.Fprintf(w, "ParseMultipartForm() err: %v", err) + w.WriteHeader(http.StatusInternalServerError) + log.Printf("ParseMultipartForm err: %v\n", err) + fmt.Fprintf(w, "ParseMultipartForm err: %v\n", err) return } mail := req.FormValue("mail") msg := req.FormValue("msg") - fmt.Fprintf(w, "Mail: %s\n", mail) - fmt.Fprintf(w, "Msg: %s\n", msg) + //fmt.Fprintf(w, "Mail: %s\n", mail) + //fmt.Fprintf(w, "Msg: %s\n", msg) log.Printf("Form: %+v\n", req.Form) - if (mail == "" || msg == "") { - fmt.Fprintf(w, "mail and msg required, try again: 'curl %s/book/%s -F 'mail=' -F 'msg='", s.config.HiveDavHost, dtstart) + if mail == "" || msg == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Mail and msg required, try again: 'curl %s/book/%s -F 'mail=' -F 'msg='\n", s.config.HiveDavHost, dtstart) return } - s.sendMail(mail, msg, dtstartTime) - // TODO: never returns + err = s.sendMail(mail, msg, dtStartTimeLocal) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Printf("Error occured while sending mail: %v\n", err) + fmt.Fprintf(w, "Error occured while sending mail: %v\n", err) + return + } + // TODO: print success message to user return } // book a time slot in the calendar -func (s *Server) sendMail(bee string, msg string, dtstart time.Time) { +func (s *Server) sendMail(recepient string, msg string, dtstart time.Time) error { + _, err := netmail.ParseAddress(recepient) + if err != nil { + return err + } - auth := smtp.PlainAuth("", s.config.SmtpUser, s.config.SmtpPassword, s.config.SmtpServer) - bees := []string{ + e := email.NewEmail() + + e.From = s.config.SmtpUser + e.To = []string{ s.config.CaldavUser, - bee, + recepient, } + e.Subject = s.config.BookingSummary + e.Text = []byte(msg) - var body bytes.Buffer - body.Write([]byte(fmt.Sprintln("Subject: HiveDAV test"))) - err := smtp.SendMail(s.config.SmtpServer+":"+"587", auth, s.config.SmtpUser, bees, body.Bytes()) + // parse ics booking template + icsTmpl, err := template.ParseFiles("templates/booking.ics") if err != nil { - log.Println("Error sending mail") + return err + } + + // create ics booking from template + tmpFileName := fmt.Sprintf("/tmp/%s", dtstart.Format("2006-01-02-15")) + icsFile, err := os.Create(tmpFileName) + if err != nil { + return err + } + defer icsFile.Close() + defer os.Remove(tmpFileName) + + //uuid := uuid.New() + uuid, _ := genUid(12) + + // get timezone and offset + // TODO: This will probably have to change for winter (standard) time + // TODO: Check the 1/2h timezones + zone, offsetSec := dtstart.Zone() + offsetHours := offsetSec / 60 / 60 + + var ( + dstTzOffsetTo string + dstTzOffsetFrom string + standardTzOffsetTo string + standardTzOffsetFrom string + ) + + if dtstart.IsDST() { + dstTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours)) + standardTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours)) + dstTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours - 1)) + standardTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours - 1)) + } else { + // Winter/Standard time + standardTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours)) + dstTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours)) + standardTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours + 1)) + dstTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours + 1)) + } + + icsData := IcsData{ + Prodid: fmt.Sprintf("-//HiveDAV//%s//EN", hivedavVersion), + Uid: strings.ToUpper(uuid), + Timestamp: time.Now().Format("20060102T150405Z"), + Dtstart: dtstart.Format("20060102T150405"), + Dtend: dtstart.Add(time.Hour).Format("20060102T150405"), + Timezone: zone, + StandardTzOffsetFrom: standardTzOffsetFrom, + StandardTzOffsetTo: standardTzOffsetTo, + DstTzOffsetFrom: dstTzOffsetFrom, + DstTzOffsetTo: dstTzOffsetTo, + Summary: s.config.BookingSummary, + Reminder: s.config.BookingReminder, + Description: msg, + Location: fmt.Sprintf("%s/%s", s.config.BookingLocation, strings.ToUpper(uuid)), + Organizer: s.config.CaldavUser, + Attendee: recepient, + } + + log.Printf("IcsData: +%v\n", icsData) + + err = icsTmpl.Execute(icsFile, icsData) + if err != nil { + return err + } + + e.AttachFile(tmpFileName) + // https://datatracker.ietf.org/doc/html/rfc2447#section-2.4 + e.Attachments[0].ContentType = "text/calendar; charset=utf-8; method=REQUEST" + log.Printf("%s\n", e.Attachments[0].Content) + smtpServer := fmt.Sprintf("%s:%d", s.config.SmtpHost, s.config.SmtpPort) + auth := smtp.PlainAuth("", s.config.SmtpUser, s.config.SmtpPassword, s.config.SmtpHost) + + tlsConfig := tls.Config{ + InsecureSkipVerify: true, + } + // TODO: Add config option to send without TLS + //err = e.SendWithStartTLS(smtpServer, auth, nil) + err = e.SendWithStartTLS(smtpServer, auth, &tlsConfig) + if err != nil { + return err + } + + return nil +} + +// https://datatracker.ietf.org/doc/html/rfc7986#section-5.3 +func genUid(n int) (string, error) { + bytes := make([]byte, n) + if _, err := rand.Read(bytes); err != nil { + return "", err } + return hex.EncodeToString(bytes), nil } diff --git a/templates/booking.ics b/templates/booking.ics @@ -0,0 +1,40 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:{{ .Prodid }} +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VTIMEZONE +TZID:{{ .Timezone }} +BEGIN:STANDARD +DTSTART:19700101T030000 +TZOFFSETFROM:{{ html .StandardTzOffsetFrom }} +TZOFFSETTO:{{ html .StandardTzOffsetTo }} +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10;WKST=SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700101T020000 +TZOFFSETFROM:{{ html .DstTzOffsetFrom }} +TZOFFSETTO:{{ html .DstTzOffsetTo }} +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3;WKST=SU +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;PARTSTAT=ACCEPTED:mailto:{{ .Organizer }} +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{{ .Attendee }} +ORGANIZER:mailto:{{ .Organizer }} +CREATED:{{ .Timestamp }} +DTSTAMP:{{ .Timestamp }} +LAST-MODIFIED:{{ .Timestamp }} +DTSTART;TZID={{ .Timezone }}:{{ .Dtstart }} +DTEND;TZID={{ .Timezone }}:{{ .Dtend}} +SUMMARY:{{ .Summary }} +DESCRIPTION:{{ .Description }} +UID:{{ .Uid }} +LOCATION:{{ .Location }} +BEGIN:VALARM +TRIGGER:-PT{{ .Reminder }}M +ACTION:DISPLAY +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/templates/index.html b/templates/index.html @@ -8,6 +8,9 @@ <p> Serving week <i>{{ .Week }}</i> of year <i>{{ .Year }}</i> </p> + <p> + This site is curlable, try <code>`curl {{ .HiveDavHost }}`</code> + </p> <table> <tr> {{ range $i, $h := .TableHead }} diff --git a/templates/listcubicles.html b/templates/listcubicles.html @@ -0,0 +1,28 @@ +<html> + <head> + <title>HiveDAV - ListCubicles {{ .Week }}, Year {{ .Year }}</title> + <link rel="stylesheet" href="/css/index.css"> + <link rel="stylesheet" href="/css/style.css"> + </head> + <body> + <p> + Serving week <i>{{ .Week }}</i> of year <i>{{ .Year }}</i> + </p> + <table> + <tr> + {{ range $i, $h := .TableHead }} + <th>{{ $h }}</th> + {{ end }} + </tr> + {{ range $i, $r := .Rows }} + <tr> + <td>{{ index $r 0 }}</td> + <td>{{ index $r 1 }}</td> + </tr> + {{ end }} + </table> + <footer> + HiveDAV <a href="https://code.in0rdr.ch/hivedav/refs.html">{{ .Version }}</a> &#127855; + </footer> + </body> +</html>