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:
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" />