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 93f3f7ee6b4259c3b42b6cf698ecf5b15da97f17
parent 91fff6f662c37479795ffa75e3538af64c82cd9f
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Wed, 20 Sep 2023 01:32:39 +0200

feat(book): draft curlable booking process

Diffstat:
MREADME.md | 33++++++++++++++++++++++-----------
Mapp.env.example | 4++++
Mconfig/config.go | 8++++++++
Mmain.go | 7+++++--
Mserver.go | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 216 insertions(+), 16 deletions(-)

diff --git a/README.md b/README.md @@ -52,26 +52,36 @@ The application is configurable through environment variables or the #HIVEDAV_CALENDAR=0 # Required +HIVEDAV_HOST=hivedav.example.com HIVEDAV_CALDAV_URI= HIVEDAV_CALDAV_USER= HIVEDAV_CALDAV_PASSWORD= HIVEDAV_REFRESH_INTERVAL=30 +HIVEDAV_SMTP_SERVER= +HIVEDAV_SMTP_USER= +HIVEDAV_SMTP_PASSWORD= ``` There is an example config file provided in `./app.env.example`. -The `HIVEDAV_CALENDAR` is a number that points to the index of the calendar -collection in the ["calendar home +* 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 + 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. - -TODO: specify calendar using the "Name" propery - -`HIVEDAV_REFRESH_INTERVAL` is the time in minutes between the automatic refresh -of the database with the latest availability data from the CalDAV server. A -[cron job](https://pkg.go.dev/github.com/robfig/cron) automatically downloads -the latest events from the CalDAV server after that time. Defaults to 30 -minutes. +be used to read availability data +* `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 + the avialability data +* TODO: specify calendar using the "Name" propery +* `HIVEDAV_REFRESH_INTERVAL` is the time in minutes between the automatic + refresh of the database with the latest availability data from the CalDAV +server. A [cron job](https://pkg.go.dev/github.com/robfig/cron) automatically +downloads the latest events from the CalDAV server after that time. Defaults to +30 minutes. +* The `HIVEDAV_SMTP_*` settings are required to send out the ical invites for + new appointments. ## Storage The application stores the event start/end times in an sqlite database in the @@ -162,6 +172,7 @@ stored in a local database for further processing. ``` TODO: +- Time slots are 1h each (TODO: make it configurable) - Sqlite date comparison always inclusive? - Picture for the query 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 #HIVEDAV_CALDAV_URI= #HIVEDAV_CALENDAR=0 #HIVEDAV_CALDAV_USER= #HIVEDAV_CALDAV_PASSWORD= #HIVEDAV_REFRESH_INTERVAL=30 +#HIVEDAV_SMTP_SERVER= +#HIVEDAV_SMTP_USER= +#HIVEDAV_SMTP_PASSWORD= diff --git a/config/config.go b/config/config.go @@ -6,6 +6,7 @@ import ( ) type Config struct { + HiveDavHost string `mapstructure:"HIVEDAV_HOST"` ListenAddress string `mapstructure:"HIVEDAV_LISTEN_ADDRESS"` ListenPort int `mapstructure:"HIVEDAV_LISTEN_PORT"` CaldavUri string `mapstructure:"HIVEDAV_CALDAV_URI"` @@ -13,6 +14,9 @@ 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"` + SmtpUser string `mapstructure:"HIVEDAV_SMTP_USER"` + SmtpPassword string `mapstructure:"HIVEDAV_SMTP_PASSWORD"` } func (c *Config) LoadConfig(path string) (*Config, error) { @@ -23,6 +27,7 @@ func (c *Config) LoadConfig(path string) (*Config, error) { viper.SetConfigType("env") // define some defaults + viper.SetDefault("HIVEDAV_HOST", "hivedav.example.com") viper.SetDefault("HIVEDAV_LISTEN_PORT", 3737) viper.SetDefault("HIVEDAV_LISTEN_ADDRESS", "[::]") viper.SetDefault("HIVEDAV_CALENDAR", 0) @@ -51,6 +56,9 @@ func (c *Config) LoadConfig(path string) (*Config, error) { if viper.IsSet("LISTEN_PORT") { c.ListenPort = viper.GetInt("LISTEN_PORT") } + if viper.IsSet("HOST") { + c.CaldavUri = viper.GetString("HOST") + } if viper.IsSet("CALDAV_URI") { c.CaldavUri = viper.GetString("CALDAV_URI") } diff --git a/main.go b/main.go @@ -27,8 +27,8 @@ func main() { log.Fatal("Error creating server with config: ", err) } - log.Printf("Starting hivedav %s 🍯\n", hivedavVersion) - log.Printf("Cron availability update is %d minutes ⏰\n", conf.RefreshInterval) + 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) @@ -40,6 +40,9 @@ func main() { router := httprouter.New() router.GET("/", server.Index) router.GET("/week/:week", server.Week) + router.GET("/list", server.ListCubicles) + router.GET("/list/:week", server.ListCubiclesInWeek) + 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) diff --git a/server.go b/server.go @@ -18,6 +18,8 @@ import ( "strconv" "strings" "time" + "net/smtp" + "bytes" ) type Server struct { @@ -177,7 +179,7 @@ func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Pa tableHead := []string{ "Time ", fmt.Sprintf("Mon %s", monday.Format("02.01.")), - fmt.Sprintf("Tue %s", monday.Add(time.Hour).Format("02.01.")), + fmt.Sprintf("Tue %s", monday.Add(time.Hour*24).Format("02.01.")), fmt.Sprintf("Wed %s", monday.Add(time.Hour*24*2).Format("02.01.")), fmt.Sprintf("Thu %s", monday.Add(time.Hour*24*3).Format("02.01.")), fmt.Sprintf("Fri %s", monday.Add(time.Hour*24*4).Format("02.01.")), @@ -233,6 +235,7 @@ func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Pa if strings.Contains(userAgent, "curl") { w.Header().Set("Content-Type", "text/plain") io.WriteString(w, fmt.Sprintf("Serving week %d of year %d\n", week, year)) + io.WriteString(w, fmt.Sprintf("Use 'curl %s/list/%d' to list cubicle booking commands\n", s.config.HiveDavHost, week)) pt := prettytable.New(tableData.TableHead) for _, r := range tableData.Rows { @@ -439,8 +442,179 @@ func (s *Server) Healthz(w http.ResponseWriter, req *http.Request, _ httprouter. } } -func (s *Server) showCubicles() { +func (s *Server) ListCubicles(w http.ResponseWriter, req *http.Request, _ httprouter.Params) { + timeNow := time.Now() + _, currentWeek := timeNow.ISOWeek() + ps := httprouter.Params{ + httprouter.Param{"week", strconv.Itoa(currentWeek)}, + } + s.ListCubiclesInWeek(w, req, ps) +} + +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() + weekday := timeNow.Weekday() + year, week := timeNow.ISOWeek() + + // overwrite with requested 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 + return + } + + monday, week := dayOfISOWeek(1, week, year) + log.Printf("Current weekday is '%d'\n", weekday) + log.Printf("Serving week '%v' of year '%v'\n", week, year) + log.Printf("Monday is '%v'\n", monday) + + sqlDateFmt := "2006-01-02 15:04" + + var rows [][]string + + // Define the table header + // 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 ", + } + + tableData := TableData{ + TableHead: tableHead, + Rows: rows, + Week: week, + Year: year, + Version: hivedavVersion, + } + + // 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) + + // 5 days * 9 1hour time slots = 45 possible booking commands + for d := 0; d < 5; d++ { + // 9 1hour time slots + for h := 8; h < 17; h++ { + // add/remove 1 minute to make the sql time query in available() below behave correctly + // convert to read/compare in UTC from db + startTime := timeIt.Add(time.Hour * time.Duration((d)*24+h)) + endTime := timeIt.Add(time.Hour * time.Duration((d)*24+h+1)) + start := startTime.Add(1 * time.Minute).UTC().Format(sqlDateFmt) + end := endTime.Add(-1 * time.Minute).UTC().Format(sqlDateFmt) + avi, err := s.available(start, end) + if err != nil { + log.Printf("Error getting availability on day '%d' hour '%d'\n", d, h) + return + } + if avi { + // Make two columns, for the date/time and the command + tableData.Rows = append(tableData.Rows, []string{ + fmt.Sprintf("%s - %s", startTime.Format("Mon 02.01. 15:04"), endTime.Format("15:04")), + fmt.Sprintf("curl %s/book/%s -F 'mail=' -F 'msg='", s.config.HiveDavHost, startTime.Format("2006-01-02-15")), + }) + } + } + } + + if strings.Contains(userAgent, "curl") { + w.Header().Set("Content-Type", "text/plain") + io.WriteString(w, fmt.Sprintf("Serving week %d of year %d\n", week, year)) + io.WriteString(w, fmt.Sprintf("Use 'curl %s/week/%d' to check availability\n", s.config.HiveDavHost, week)) + + pt := prettytable.New(tableData.TableHead) + for _, r := range tableData.Rows { + // convert to slice of interface + // https://go.dev/doc/faq#convert_slice_of_interface + s := make([]interface{}, len(r)) + for i, v := range r { + s[i] = v + } + // unpack the string array into row arguments + pt.AddRow(s...) + } + + io.WriteString(w, fmt.Sprint(pt)) + io.WriteString(w, fmt.Sprintf("HiveDAV %s 🍯\n", hivedavVersion)) + } else { + w.Header().Set("Content-Type", "text/html") + + 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 + 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 + return + } + } } -func (s *Server) reserveCubicle() { +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) + if err != nil { + log.Println("Error parsing booking dtstart") + return + } + log.Println("dtstart booking request: ", dtstartTime) + + // 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) + return + } + + mail := req.FormValue("mail") + msg := req.FormValue("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) + return + } + + s.sendMail(mail, msg, dtstartTime) + // TODO: never returns + return +} + +// book a time slot in the calendar +func (s *Server) sendMail(bee string, msg string, dtstart time.Time) { + + auth := smtp.PlainAuth("", s.config.SmtpUser, s.config.SmtpPassword, s.config.SmtpServer) + bees := []string{ + s.config.CaldavUser, + bee, + } + + 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()) + if err != nil { + log.Println("Error sending mail") + } }