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 e44d07bf63ad1b82ee2a03fab29b05b72a0aa0c0
parent cb30c46aa50f8f9c3661480467df4067abdc0a56
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Thu,  5 Oct 2023 02:25:49 +0200

feat: starttls, input dtstart, double boookings

Adds:
* smtp starttls config option
* prefer input form dtstart from user input
* avoid double boookings, add error message

Diffstat:
MREADME.md | 1+
Mapp.env.example | 1+
Mconfig/config.go | 5+++++
Mserver.go | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mtemplates/booking.ics | 1-
Mtemplates/cubicleform.html | 2+-
6 files changed, 103 insertions(+), 34 deletions(-)

diff --git a/README.md b/README.md @@ -53,6 +53,7 @@ The application is configurable through environment variables or the #HIVEDAV_BOOKING_SUBJ="HiveDAV Booking" #HIVEDAV_BOOKING_REMINDER=15 #HIVEDAV_SMTP_PORT=587 +#HIVEDAV_SMTP_STARTTLS=true # Required HIVEDAV_HOST=hivedav.example.com diff --git a/app.env.example b/app.env.example @@ -8,6 +8,7 @@ #HIVEDAV_REFRESH_INTERVAL=30 #HIVEDAV_SMTP_HOST= #HIVEDAV_SMTP_PORT=587 +#HIVEDAV_SMTP_STARTTLS=true #HIVEDAV_SMTP_USER= #HIVEDAV_SMTP_PASSWORD= #HIVEDAV_BOOKING_SUBJ="HiveDAV Booking" diff --git a/config/config.go b/config/config.go @@ -16,6 +16,7 @@ type Config struct { RefreshInterval int `mapstructure:"HIVEDAV_REFRESH_INTERVAL"` SmtpHost string `mapstructure:"HIVEDAV_SMTP_HOST"` SmtpPort int `mapstructure:"HIVEDAV_SMTP_PORT"` + SmtpStartTls bool `mapstructure:"HIVEDAV_SMTP_STARTTLS"` SmtpUser string `mapstructure:"HIVEDAV_SMTP_USER"` SmtpPassword string `mapstructure:"HIVEDAV_SMTP_PASSWORD"` BookingSummary string `mapstructure:"HIVEDAV_BOOKING_SUMMARY"` @@ -40,6 +41,7 @@ func (c *Config) LoadConfig(path string) (*Config, error) { viper.SetDefault("HIVEDAV_BOOKING_LOCATION", "https://meet.jit.si") viper.SetDefault("HIVEDAV_BOOKING_REMINDER", 15) viper.SetDefault("HIVEDAV_SMTP_PORT", 587) + viper.SetDefault("HIVEDAV_SMTP_STARTTLS", true) err := viper.ReadInConfig() if err != nil { @@ -88,6 +90,9 @@ func (c *Config) LoadConfig(path string) (*Config, error) { if viper.IsSet("SMTP_PORT") { c.SmtpPort = viper.GetInt("SMTP_PORT") } + if viper.IsSet("SMTP_STARTTLS") { + c.SmtpStartTls = viper.GetBool("SMTP_STARTTLS") + } if viper.IsSet("SMTP_USER") { c.SmtpUser = viper.GetString("SMTP_USER") } diff --git a/server.go b/server.go @@ -622,7 +622,7 @@ func (s *Server) ListCubiclesInWeek(w http.ResponseWriter, req *http.Request, ps func (s *Server) CubicleForm(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { dtstart := ps.ByName("dtstart") - dtStartTimeLocal, err := time.ParseInLocation("2006-01-02-15", dtstart, time.Local) + dtStartPathTimeLocal, err := time.ParseInLocation("2006-01-02-15", dtstart, time.Local) if err != nil { log.Printf("Error parsing booking dtstart: %v\n", err) @@ -631,7 +631,7 @@ func (s *Server) CubicleForm(w http.ResponseWriter, req *http.Request, ps httpro } icsData := CubicleFormData{ - DtstartInput: dtStartTimeLocal.Format("2006-01-02T15:04"), + DtstartInput: dtStartPathTimeLocal.Format("2006-01-02T15:04"), Dtstart: dtstart, Version: hivedavVersion, } @@ -653,16 +653,17 @@ func (s *Server) CubicleForm(w http.ResponseWriter, req *http.Request, ps httpro } func (s *Server) BookCubicle(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - dtstart := ps.ByName("dtstart") + dtStartPath := ps.ByName("dtstart") + //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) + dtStartPathTimeLocal, err := time.ParseInLocation("2006-01-02-15", dtStartPath, time.Local) if err != nil { - log.Printf("Error parsing booking dtstart: %v\n", err) + log.Printf("Error parsing booking dtstart from request path: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - log.Printf("dtstart booking request (local time): %v\n", dtStartTimeLocal) + log.Printf("dtstart booking request (local time): %v\n", dtStartPathTimeLocal) // total of maxMemory bytes of its file parts are stored in memory // with the remainder stored on disk in temporary files @@ -674,7 +675,29 @@ func (s *Server) BookCubicle(w http.ResponseWriter, req *http.Request, ps httpro return } + inputDateTimeFmt := "2006-01-02T15:04" + dtStartInput := req.FormValue("dtstart") + // TODO: replace local time by timezone specified in config file + dtStartInputTime, err := time.ParseInLocation(inputDateTimeFmt, dtStartInput, time.Local) + if err != nil { + log.Printf("Error parsing booking dtstart from user input: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("dtstart booking request (local time): %v\n", dtStartPathTimeLocal) + + dtstart := dtStartPathTimeLocal + if dtStartPathTimeLocal != dtStartInputTime { + // prefer time slot chosen by the user (html input field) + dtstart = dtStartInputTime + log.Printf("Preferring user input dtstart '%v' from the form\n", dtStartInput) + } + + log.Printf("dtStartInput: %v\n", dtStartInput) + log.Printf("dtStartPath: %v\n", dtStartPath) + mail := req.FormValue("mail") + //msg := req.FormValue("msg") msg := html.UnescapeString(req.FormValue("msg")) if mail == "" || msg == "" { @@ -682,15 +705,44 @@ func (s *Server) BookCubicle(w http.ResponseWriter, req *http.Request, ps httpro return } - err = s.sendMail(mail, msg, dtStartTimeLocal) + // TODO: move this 1 minute logic + sqlDateFmt into available() function + // add/remove 1 minute to make the sql time query in available() below behave correctly + // convert to read/compare in UTC from db + sqlDateFmt := "2006-01-02 15:04" + start := dtstart.Add(1 * time.Minute).UTC().Format(sqlDateFmt) + end := dtstart.Add(time.Hour).Add(-1 * time.Minute).UTC().Format(sqlDateFmt) + avi, err := s.available(start, end) + if err != nil { + log.Printf("Error getting availability on '%s'\n", start) + return + } + + // check if the slot is still available + if !avi { + log.Printf("Duplicate booking detected for requestor: %s\n", mail) + http.Error(w, fmt.Sprintf("This cubicle is already booked. Choose a free slot: 'curl %s/list'\n", s.config.HiveDavHost), http.StatusBadRequest) + return + } + + err = s.sendMail(mail, msg, dtstart) if err != nil { log.Printf("Error occured while sending mail: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - zone, _ := dtStartTimeLocal.Zone() - io.WriteString(w, fmt.Sprintf("Thank you for booking on %s %s\n", dtStartTimeLocal.Format("02 Jan 2006 15:04"), zone)) + // update availability + startTimeUtc := dtstart.UTC() + endTimeUtc := dtstart.Add(time.Hour).UTC() + if _, err = s.db.Exec("INSERT INTO availability VALUES(NULL, ?, ?, false);", startTimeUtc, endTimeUtc); err != nil { + log.Printf("Error updating availability database with new booking: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + zone, _ := dtstart.Zone() + io.WriteString(w, fmt.Sprintf("Thank you for booking on %s %s\n", dtstart.Format("02 Jan 2006 15:04"), zone)) + return } @@ -718,15 +770,14 @@ func (s *Server) sendMail(recepient string, msg string, dtstart time.Time) error } // create ics booking from template - tmpFileName := fmt.Sprintf("/tmp/%s", dtstart.Format("2006-01-02-15")) - icsFile, err := os.Create(tmpFileName) + tmpFilePath := fmt.Sprintf("/tmp/%s.ics", dtstart.Format("2006-01-02-15")) + icsFile, err := os.Create(tmpFilePath) if err != nil { return err } defer icsFile.Close() - defer os.Remove(tmpFileName) + defer os.Remove(tmpFilePath) - //uuid := uuid.New() uuid, _ := genUid(12) // get timezone and offset @@ -775,25 +826,35 @@ func (s *Server) sendMail(recepient string, msg string, dtstart time.Time) error Attendee: recepient, } - log.Printf("DESCRIPTION: %v\n", icsData.Description) + log.Printf("Description: %s\n", icsData.Description) err = icsTmpl.Execute(icsFile, icsData) if err != nil { return err } - e.AttachFile(tmpFileName) + e.AttachFile(tmpFilePath) + //lastSlashIndex := strings.LastIndex(tmpFilePath, "/") + //var tmpFileName string + //if lastSlashIndex != -1 { + // tmpFileName = tmpFilePath[lastSlashIndex+1:] + //} // https://datatracker.ietf.org/doc/html/rfc2447#section-2.4 + //e.Attachments[0].ContentType = fmt.Sprintf("text/calendar; charset=utf-8; method=REQUEST; name=%s", tmpFileName) e.Attachments[0].ContentType = "text/calendar; charset=utf-8; method=REQUEST" + fmt.Println(e.Attachments[0].ContentType) 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, + if s.config.SmtpStartTls { + tlsConfig := tls.Config{ + InsecureSkipVerify: true, + } + err = e.SendWithStartTLS(smtpServer, auth, &tlsConfig) + } else { + err = e.SendWithStartTLS(smtpServer, auth, nil) } - // 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 } @@ -820,7 +881,7 @@ func fold(s string) string { escch := rune(0x0) // \0 esc := false - for i < len(runeStr)-1 || esc { + for (i < len(runeStr)-1) || esc { buf = append(buf, rune(0x0)) bufl++ @@ -830,6 +891,7 @@ func fold(s string) string { // immediately followed by a white space character buf[bufl-2] = rune(0xa) // newline '\n' escch = rune(0x20) // whitespace ' ' + esc = true continue } @@ -842,11 +904,11 @@ func fold(s string) string { // escape characters // https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11 switch runeStr[i] { - case rune(0x5c): // backslash `\` - buf[bufl-2] = rune(0x5c) - escch = rune(0x5c) - esc = true - break + //case rune(0x5c): // backslash '\' + // buf[bufl-2] = rune(0x5c) + // escch = rune(0x5c) + // esc = true + // break case rune(0x3b): // semicolon ';' buf[bufl-2] = rune(0x5c) escch = rune(0x3b) @@ -857,11 +919,12 @@ func fold(s string) string { escch = rune(0x2c) esc = true break - case rune(0xa): // newline '\n' - buf[bufl-2] = rune(0x5c) // backslash '\' - escch = rune(0x6e) // literal 'n' - esc = true - break + //case rune(0xa): // newline '\n' + // buf[bufl-2] = rune(0x5c) // backslash '\' + // escch = rune(0x6e) // literal 'n' + // //escch = rune(0xa) // newline '\n' + // esc = true + // break default: // write regular character from runeStr buf[bufl-2] = runeStr[i] @@ -871,7 +934,7 @@ func fold(s string) string { } // terminate the char string in any case (esc or not) - buf[bufl-1] = rune(0x0) + //buf[bufl-1] = rune(0x0) } return string(buf) diff --git a/templates/booking.ics b/templates/booking.ics @@ -20,7 +20,6 @@ END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT STATUS:CONFIRMED -ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:{{ .Organizer }} ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{{ .Attendee }} ORGANIZER:mailto:{{ .Organizer }} CREATED:{{ .Timestamp }} diff --git a/templates/cubicleform.html b/templates/cubicleform.html @@ -12,7 +12,7 @@ </nav> Reserve cubicle for 1 hour <form method="post" enctype="multipart/form-data" action="/book/{{ .Dtstart }}" class="cubicleform"> - <label for="dtstart">Date and time:</label><input type="datetime-local" value="{{ .DtstartInput }}" id="dtstart" /> + <label for="dtstart">Date and time:</label><input type="datetime-local" value="{{ .DtstartInput }}" id="dtstart" name="dtstart" /> <label for="mail">Email:</label><input type="email" id="mail" name="mail" /> <label for="mail">Your message:</label><textarea id="msg" name="msg"></textarea> <input type="submit" value="Book me" />