hivedav

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

server.go (29653B)


      1 package main
      2 
      3 import (
      4 	"crypto/rand"
      5 	"crypto/tls"
      6 	"database/sql"
      7 	"encoding/hex"
      8 	"fmt"
      9 	"github.com/emersion/go-ical"
     10 	"github.com/jordan-wright/email"
     11 	"github.com/julienschmidt/httprouter"
     12 	_ "github.com/mattn/go-sqlite3"
     13 	"github.com/pquerna/termchalk/prettytable"
     14 	"github.com/teambition/rrule-go"
     15 	"hivedav/caldav"
     16 	"hivedav/config"
     17 	"hivedav/tzdb"
     18 	"html"
     19 	"html/template"
     20 	"io"
     21 	"log"
     22 	"net/http"
     23 	netmail "net/mail"
     24 	"net/smtp"
     25 	"os"
     26 	"regexp"
     27 	"strconv"
     28 	"strings"
     29 	"time"
     30 )
     31 
     32 type Server struct {
     33 	config *config.Config
     34 	db     *sql.DB
     35 }
     36 
     37 type TableData struct {
     38 	TableHead   []string
     39 	Rows        [][]string
     40 	Week        int
     41 	Year        int
     42 	Version     string
     43 	HiveDavHost string
     44 }
     45 
     46 type CubicleFormData struct {
     47 	Dtstart      string
     48 	DtstartInput string
     49 	Version      string
     50 }
     51 
     52 type IcsData struct {
     53 	Timestamp            string
     54 	Prodid               string
     55 	Uid                  string
     56 	Dtstart              string
     57 	Dtend                string
     58 	Timezone             string
     59 	StandardTzOffsetFrom string
     60 	StandardTzOffsetTo   string
     61 	DstTzOffsetFrom      string
     62 	DstTzOffsetTo        string
     63 	Summary              string
     64 	Reminder             int
     65 	Description          string
     66 	Location             string
     67 	Organizer            string
     68 	Attendee             string
     69 }
     70 
     71 var drop = `DROP TABLE IF EXISTS availability;
     72 	    DROP TABLE IF EXISTS availability_1;`
     73 var create_availability = `CREATE TABLE IF NOT EXISTS availability (
     74 			   id INTEGER NOT NULL PRIMARY KEY,
     75 			   start DATETIME NOT NULL,
     76 			   end DATETIME NOT NULL,
     77 			   recurring BOOL NOT NULL);`
     78 var create_availability_1 = `CREATE TABLE IF NOT EXISTS availability_1 (
     79 			     id INTEGER NOT NULL PRIMARY KEY,
     80 			     start DATETIME NOT NULL,
     81 			     end DATETIME NOT NULL,
     82 			     recurring BOOL NOT NULL);`
     83 
     84 var available = `SELECT id FROM availability WHERE
     85 		 (start >= ? AND start < ?) OR
     86 		 (end > ? AND end <= ?) OR
     87 		 (start <= ? AND end >= ?);`
     88 
     89 func (s *Server) NewServer(c *config.Config) (*Server, error) {
     90 	s = new(Server)
     91 	s.config = c
     92 
     93 	// prepare the sqlite database
     94 	db, err := sql.Open("sqlite3", "app.db")
     95 	if err != nil {
     96 		log.Fatal("Error opening sqlite3 database 'app.db'", err)
     97 	}
     98 	s.db = db
     99 
    100 	if _, err := db.Exec(drop); err != nil {
    101 		return nil, err
    102 	}
    103 	if _, err := db.Exec(create_availability); err != nil {
    104 		return nil, err
    105 	}
    106 	if _, err := db.Exec(create_availability_1); err != nil {
    107 		return nil, err
    108 	}
    109 
    110 	return s, nil
    111 }
    112 
    113 // Returns Time with requested weekday and week. If out of bounds, the returned
    114 // week is set to the maximum for the specific year.
    115 // https://xferion.com/golang-reverse-isoweek-get-the-date-of-the-first-day-of-iso-week
    116 func dayOfISOWeek(weekday int, week int, year int) (timeIt time.Time, weekNow int) {
    117 	// check out of bounds week (last week of the year)
    118 	limit := time.Date(year, 12, 31, 0, 0, 0, 0, time.Local)
    119 	_, limitWeek := limit.ISOWeek()
    120 	if limitWeek == 1 {
    121 		// last day of year already in first week of next year
    122 		// go back a week (years, months, days)
    123 		limit = limit.AddDate(0, 0, -7)
    124 		_, limitWeek = limit.ISOWeek()
    125 	}
    126 
    127 	if week > limitWeek {
    128 		// set week to last week of requested year
    129 		week = limitWeek
    130 	}
    131 
    132 	// start time iterator in week 0
    133 	timeIt = time.Date(year, 0, 0, 0, 0, 0, 0, time.Local)
    134 	yearNow, weekNow := timeIt.ISOWeek()
    135 
    136 	// iterate back to the weekday
    137 	for timeIt.Weekday() != time.Weekday(weekday) {
    138 		// years, months, days
    139 		timeIt = timeIt.AddDate(0, 0, -1)
    140 		yearNow, weekNow = timeIt.ISOWeek()
    141 	}
    142 
    143 	// if we iterated back into the old year, iterate forward in weekly
    144 	// steps (7) until we reach the weekday of the first week in yearNow
    145 	for yearNow < year {
    146 		timeIt = timeIt.AddDate(0, 0, 7)
    147 		yearNow, weekNow = timeIt.ISOWeek()
    148 	}
    149 
    150 	// iterate forward to the first day of the given ISO week
    151 	for weekNow < week {
    152 		timeIt = timeIt.AddDate(0, 0, 7)
    153 		yearNow, weekNow = timeIt.ISOWeek()
    154 	}
    155 
    156 	return timeIt, weekNow
    157 }
    158 
    159 func (s *Server) available(start string, end string) (bool, error) {
    160 	var id int
    161 
    162 	stmt, err := s.db.Prepare(available)
    163 
    164 	if err != nil {
    165 		log.Println("Error during db statement Prepare()")
    166 		return false, err
    167 	}
    168 
    169 	err = stmt.QueryRow(start, end, start, end, start, end).Scan(&id)
    170 	if err != nil {
    171 		if err == sql.ErrNoRows {
    172 			// We are available if we cannot find any rows
    173 			return true, nil
    174 		}
    175 		return false, err
    176 	}
    177 	return false, nil
    178 }
    179 
    180 var funcMap = template.FuncMap{
    181 	"next": func(i int) int { return i + 1 },
    182 	"prev": func(i int) int { return i - 1 },
    183 	"mkSlice": func(a int, b int) []int {
    184 		// return slice of integers from a to b
    185 		var r []int
    186 		for i := a; i <= b; i++ {
    187 			r = append(r, i)
    188 		}
    189 		return r
    190 	},
    191 }
    192 
    193 func (s *Server) Week(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
    194 	userAgent := req.Header.Get("user-agent")
    195 
    196 	// overwrite with requested year and week
    197 	year, err := strconv.Atoi(ps.ByName("year"))
    198 	if err != nil {
    199 		log.Printf("Cannot serve requested year '%d'\n", year)
    200 		http.Error(w, fmt.Sprintf("Cannot serve requested year '%d'\n", year), http.StatusBadRequest)
    201 		return
    202 	}
    203 	week, err := strconv.Atoi(ps.ByName("week"))
    204 	if err != nil {
    205 		log.Printf("Cannot serve requested week '%d'\n", week)
    206 		http.Error(w, fmt.Sprintf("Cannot serve requested week '%d'\n", week), http.StatusBadRequest)
    207 		return
    208 	}
    209 
    210 	monday, week := dayOfISOWeek(1, week, year)
    211 	log.Printf("Serving week '%v' of year '%v'\n", week, year)
    212 	log.Printf("Monday is '%v'\n", monday)
    213 
    214 	sqlDateFmt := "2006-01-02 15:04:00"
    215 
    216 	termRows := make([][]string, 9)
    217 	for i := range termRows {
    218 		termRows[i] = make([]string, 6)
    219 	}
    220 	htmlRows := make([][]string, 9)
    221 	for i := range htmlRows {
    222 		htmlRows[i] = make([]string, 6)
    223 	}
    224 
    225 	// Define the table header
    226 	// TODO: Make heading date format configurable, start of week, etc..
    227 	tableHead := []string{
    228 		"Time      ",
    229 		fmt.Sprintf("Mon %s", monday.Format("02.01.")),
    230 		fmt.Sprintf("Tue %s", monday.Add(time.Hour*24).Format("02.01.")),
    231 		fmt.Sprintf("Wed %s", monday.Add(time.Hour*24*2).Format("02.01.")),
    232 		fmt.Sprintf("Thu %s", monday.Add(time.Hour*24*3).Format("02.01.")),
    233 		fmt.Sprintf("Fri %s", monday.Add(time.Hour*24*4).Format("02.01.")),
    234 	}
    235 
    236 	termTableData := TableData{
    237 		TableHead:   tableHead,
    238 		Rows:        termRows,
    239 		Week:        week,
    240 		Year:        year,
    241 		Version:     hivedavVersion,
    242 		HiveDavHost: s.config.HiveDavHost,
    243 	}
    244 	htmlTableData := TableData{
    245 		TableHead:   tableHead,
    246 		Rows:        htmlRows,
    247 		Week:        week,
    248 		Year:        year,
    249 		Version:     hivedavVersion,
    250 		HiveDavHost: s.config.HiveDavHost,
    251 	}
    252 
    253 	// TODO: use timeIt to go through the loops below, remove the static arrays
    254 	// TODO: make display timezone configurable, display in local time
    255 	timeIt := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, time.Local)
    256 	//dayEnd := time.Date(monday.Year(), monday.Month(), monday.Day(), 17, 0, 0, 0, time.Local)
    257 	//weekEnd := dayEnd.Add(time.Hour * 24*5)
    258 
    259 	// Working hours - Eight to Five
    260 	//for h := 8; h < 17; h++ {
    261 	for _, h := range []int{8, 9, 10, 11, 12, 13, 14, 15, 16} {
    262 		var htmlAvailability [5]string
    263 		var termAvailability [5]string
    264 		// Working days - Monday through Friday
    265 		//for d := 0; d < 5; d++ {
    266 		for _, d := range []int{1, 2, 3, 4, 5} {
    267 			// convert to read/compare in UTC from db
    268 			startTime := timeIt.Add(time.Hour * time.Duration((d-1)*24+h))
    269 			start := startTime.UTC().Format(sqlDateFmt)
    270 			endTime := timeIt.Add(time.Hour * time.Duration((d-1)*24+h+1))
    271 			end := endTime.UTC().Format(sqlDateFmt)
    272 			avi, err := s.available(start, end)
    273 			if err != nil {
    274 				log.Printf("Error getting availability on day '%d' hour '%d'\n", d, h)
    275 				return
    276 			}
    277 			if avi {
    278 				// terminal shows free slots as empty
    279 				termAvailability[d-1] = ""
    280 				// web UI shows a booking link for free slots
    281 				htmlAvailability[d-1] = fmt.Sprintf("%s", startTime.Format("2006-01-02-15"))
    282 			} else {
    283 				termAvailability[d-1] = "X"
    284 				htmlAvailability[d-1] = ""
    285 			}
    286 			//timeIt = timeIt.Add(time.Hour * 24)
    287 		}
    288 		termTableData.Rows[h-8] = []string{
    289 			fmt.Sprintf("%02d:00 - %02d:00", h, h+1),
    290 			termAvailability[0],
    291 			termAvailability[1],
    292 			termAvailability[2],
    293 			termAvailability[3],
    294 			termAvailability[4],
    295 		}
    296 		htmlTableData.Rows[h-8] = []string{
    297 			fmt.Sprintf("%02d:00 - %02d:00", h, h+1),
    298 			htmlAvailability[0],
    299 			htmlAvailability[1],
    300 			htmlAvailability[2],
    301 			htmlAvailability[3],
    302 			htmlAvailability[4],
    303 		}
    304 		//timeIt = timeIt.Add(time.Hour)
    305 	}
    306 
    307 	if strings.Contains(userAgent, "curl") {
    308 		w.Header().Set("Content-Type", "text/plain")
    309 		io.WriteString(w, fmt.Sprintf("Serving week %d of year %d\n", week, year))
    310 		io.WriteString(w, fmt.Sprintf("List cubicle booking commands: 'curl %s/list/%d/%d'\n", s.config.HiveDavHost, year, week))
    311 		io.WriteString(w, fmt.Sprintf("> Next week: 'curl %s/week/%d/%d'\n", s.config.HiveDavHost, year, week+1))
    312 		io.WriteString(w, fmt.Sprintf("< Prev week: 'curl %s/week/%d/%d'\n", s.config.HiveDavHost, year, week-1))
    313 		pt := prettytable.New(termTableData.TableHead)
    314 		for _, r := range termTableData.Rows {
    315 			// convert to slice of interface
    316 			// https://go.dev/doc/faq#convert_slice_of_interface
    317 			s := make([]interface{}, len(r))
    318 			for i, v := range r {
    319 				s[i] = v
    320 			}
    321 			// unpack the string array into row arguments
    322 			pt.AddRow(s...)
    323 		}
    324 
    325 		io.WriteString(w, fmt.Sprint(pt))
    326 		io.WriteString(w, fmt.Sprintf("HiveDAV %s 🍯\n", hivedavVersion))
    327 	} else {
    328 		w.Header().Set("Content-Type", "text/html")
    329 
    330 		tmpl, err := template.New("index.html").Funcs(funcMap).ParseFiles("./templates/index.html")
    331 		if err != nil {
    332 			log.Printf("Error parsing html template: %v\n", err)
    333 			http.Error(w, err.Error(), http.StatusInternalServerError)
    334 			return
    335 		}
    336 		err = tmpl.Execute(w, htmlTableData)
    337 		if err != nil {
    338 			log.Printf("Error executing html template: %v\n", err)
    339 			http.Error(w, err.Error(), http.StatusInternalServerError)
    340 			return
    341 		}
    342 	}
    343 }
    344 
    345 func (s *Server) Index(w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
    346 	timeNow := time.Now()
    347 	_, currentWeek := timeNow.ISOWeek()
    348 	ps := httprouter.Params{
    349 		httprouter.Param{"year", strconv.Itoa(timeNow.Year())},
    350 		httprouter.Param{"week", strconv.Itoa(currentWeek)},
    351 	}
    352 	s.Week(w, req, ps)
    353 }
    354 
    355 func (s *Server) UpdateAvailability(calData []caldav.CalData) error {
    356 	localNow := time.Now()
    357 	isDSTNow := localNow.IsDST()
    358 	// set limit to recurring events based on configured time horizon
    359 	rrLimit := time.Date(localNow.Year()+s.config.Horizon, 12, 31, 0, 0, 0, 0, time.Local)
    360 
    361 	// Walk through events and store start/end time in the database
    362 	for _, event := range calData {
    363 		dec := ical.NewDecoder(strings.NewReader(event.Data))
    364 		cal, err := dec.Decode()
    365 		var tzOffsetTo string
    366 
    367 		if err != nil {
    368 			return err
    369 		}
    370 
    371 		// check for TIMEZONE component and read TZOFFSETTO from the
    372 		// correct sub-child
    373 		for _, c := range cal.Children {
    374 			if c.Name == ical.CompTimezone {
    375 				for _, child := range c.Children {
    376 					if isDSTNow && child.Name == ical.CompTimezoneDaylight {
    377 						// summer time
    378 						tzOffsetTo = child.Props.Get(ical.PropTimezoneOffsetTo).Value
    379 					} else if !isDSTNow && child.Name == ical.CompTimezoneStandard {
    380 						// winter time
    381 						tzOffsetTo = child.Props.Get(ical.PropTimezoneOffsetTo).Value
    382 					}
    383 				}
    384 			}
    385 		}
    386 
    387 		var tzOffsetDuration time.Duration
    388 		re := regexp.MustCompile(`^([+-][0-9]{2}).*`)
    389 		matches := re.FindStringSubmatch(tzOffsetTo)
    390 		if len(matches) == 0 {
    391 			//_, offset := localNow.Zone()
    392 			//tzOffsetDuration = time.Second * time.Duration(offset)
    393 			//tzOffsetDuration = time.Second * time.Duration(-1 * offset)
    394 			//log.Printf("Cannot find time zone offset, using local offset: %v\n", tzOffsetDuration)
    395 			tzOffsetDuration = time.Duration(0)
    396 		} else {
    397 			tzOffsetDurationInt, _ := strconv.Atoi(matches[1])
    398 			tzOffsetDuration = time.Hour * time.Duration(tzOffsetDurationInt)
    399 			// reset to UTC
    400 			tzOffsetDuration = time.Hour * time.Duration(-1*tzOffsetDurationInt)
    401 		}
    402 
    403 		for _, e := range cal.Events() {
    404 			startTimeProp := e.Props.Get(ical.PropDateTimeStart)
    405 			endTimeProp := e.Props.Get(ical.PropDateTimeEnd)
    406 
    407 			if startTimeProp == nil || endTimeProp == nil {
    408 				// skip events with funny format, not useful anyways
    409 				continue
    410 			}
    411 
    412 			start := startTimeProp.Value
    413 			end := endTimeProp.Value
    414 
    415 			status := e.Props.Get("X-MICROSOFT-CDO-INTENDEDSTATUS")
    416 			statusValue := "BUSY"
    417 			if status != nil {
    418 				statusValue = status.Value
    419 			}
    420 			if statusValue == "FREE" {
    421 				// do not store "blocker" events
    422 				continue
    423 			}
    424 
    425 			// Parse all possible time formats
    426 			// Don't use ical DateTime(), has problems with some timezones
    427 			startTime, err := parseTime(start, tzOffsetDuration)
    428 			if err != nil {
    429 				return err
    430 			}
    431 			endTime, err := parseTime(end, tzOffsetDuration)
    432 			if err != nil {
    433 				return err
    434 			}
    435 			duration := endTime.Sub(startTime)
    436 
    437 			// Parse the recurrence rules
    438 			// https://github.com/emersion/go-ical/blob/master/ical.go
    439 			// https://github.com/emersion/go-ical/blob/master/components.go
    440 			roption, err := e.Props.RecurrenceRule()
    441 			if err != nil {
    442 				return err
    443 			}
    444 
    445 			// set new availability in temporary table availability_1
    446 			if _, err = s.db.Exec("INSERT INTO availability_1 VALUES(NULL, DATETIME(?), DATETIME(?), false);", startTime, endTime); err != nil {
    447 				return err
    448 			}
    449 
    450 			// Save recurring events
    451 			if roption != nil {
    452 				r, err := rrule.NewRRule(*roption)
    453 				if err != nil {
    454 					return err
    455 				}
    456 
    457 				set := rrule.Set{}
    458 				set.RRule(r)
    459 				set.DTStart(startTime)
    460 
    461 				// add exception dates to recurrence set exclusion list
    462 				exceptionDates := e.Props.Get(ical.PropExceptionDates)
    463 				if exceptionDates != nil {
    464 					exDateTime, err := parseTime(exceptionDates.Value, tzOffsetDuration)
    465 					if err != nil {
    466 						return err
    467 					}
    468 					set.ExDate(exDateTime)
    469 				}
    470 
    471 				// don't include the first event, handled outside the loop
    472 				rDates := set.Between(startTime, rrLimit, false)
    473 				for _, rrd := range rDates {
    474 					if _, err = s.db.Exec("INSERT INTO availability_1 VALUES(NULL, DATETIME(?), DATETIME(?), true);", rrd, rrd.Add(duration)); err != nil {
    475 						return err
    476 					}
    477 				}
    478 			}
    479 		}
    480 	}
    481 
    482 	// delete current availability
    483 	if _, err := s.db.Exec("DROP TABLE IF EXISTS availability"); err != nil {
    484 		return err
    485 	}
    486 	// set new availability as current availability
    487 	if _, err := s.db.Exec("ALTER TABLE availability_1 RENAME TO availability"); err != nil {
    488 		return err
    489 	}
    490 	// prepare new temporary table
    491 	if _, err := s.db.Exec(create_availability_1); err != nil {
    492 		return err
    493 	}
    494 
    495 	return nil
    496 }
    497 
    498 // parse time from local time zone and return UTC time for sqlite db
    499 func parseTime(timeStr string, offset time.Duration) (time.Time, error) {
    500 	t, err := time.Parse("20060102T150405Z", timeStr)
    501 	if err != nil {
    502 		t, err = time.Parse("20060102T150405", timeStr)
    503 	}
    504 	if err != nil {
    505 		t, err = time.Parse("20060102", timeStr)
    506 	}
    507 	if err != nil {
    508 		return time.Time{}, err
    509 	}
    510 	return t.Add(offset), nil
    511 }
    512 
    513 // https://github.com/kelseyhightower/app-healthz
    514 func (s *Server) Healthz(w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
    515 	w.Header().Set("Content-Type", "application/json")
    516 	var count = 0
    517 	row := s.db.QueryRow("SELECT count(*) FROM availability")
    518 
    519 	if err := row.Scan(&count); err == sql.ErrNoRows {
    520 		log.Println("Error reading availabilty table")
    521 	}
    522 	if count > 0 {
    523 		io.WriteString(w, fmt.Sprintf("{\"status\":\"wagwan, hive all good\",\"rows\":\"%d\",\"version\":\"%s\"}", count, hivedavVersion))
    524 	} else {
    525 		http.Error(w, fmt.Sprintf("{\"status\":\"nah fam, db not initialized\",\"rows\":\"%d\",\"version\":\"%s\"}", count, hivedavVersion), http.StatusInternalServerError)
    526 	}
    527 }
    528 
    529 func (s *Server) ListCubicles(w http.ResponseWriter, req *http.Request, _ httprouter.Params) {
    530 	timeNow := time.Now()
    531 	_, currentWeek := timeNow.ISOWeek()
    532 	ps := httprouter.Params{
    533 		httprouter.Param{"week", strconv.Itoa(currentWeek)},
    534 	}
    535 	s.ListCubiclesInWeek(w, req, ps)
    536 }
    537 
    538 func (s *Server) ListCubiclesInWeek(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
    539 	userAgent := req.Header.Get("user-agent")
    540 
    541 	// overwrite with requested year and week
    542 	year, err := strconv.Atoi(ps.ByName("year"))
    543 	if err != nil {
    544 		log.Printf("Cannot serve requested year '%d'\n", year)
    545 		http.Error(w, fmt.Sprintf("Cannot serve requested year '%d'\n", year), http.StatusBadRequest)
    546 		return
    547 	}
    548 	week, err := strconv.Atoi(ps.ByName("week"))
    549 	if err != nil {
    550 		log.Printf("Cannot serve requested week '%d'\n", week)
    551 		http.Error(w, fmt.Sprintf("Cannot serve requested week '%d'\n", week), http.StatusBadRequest)
    552 		return
    553 	}
    554 
    555 	monday, week := dayOfISOWeek(1, week, year)
    556 	log.Printf("Serving week '%v' of year '%v'\n", week, year)
    557 	log.Printf("Monday is '%v'\n", monday)
    558 
    559 	sqlDateFmt := "2006-01-02 15:04:00"
    560 
    561 	var rows [][]string
    562 
    563 	// Define the table header
    564 	// TODO: Make heading date format configurable, start of week, etc..
    565 	tableHead := []string{
    566 		"Date/Time             ",
    567 		// TODO: %!v(PANIC=String method: strings: negative Repeat count)
    568 		"Book Cubicle                                         ",
    569 	}
    570 
    571 	tableData := TableData{
    572 		TableHead:   tableHead,
    573 		Rows:        rows,
    574 		Week:        week,
    575 		Year:        year,
    576 		Version:     hivedavVersion,
    577 		HiveDavHost: s.config.HiveDavHost,
    578 	}
    579 
    580 	// TODO: use timeIt to go through the loops below, remove the static arrays
    581 	timeIt := time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, time.Local)
    582 
    583 	// 5 days * 9 1hour time slots = 45 possible booking commands
    584 	for d := 0; d < 5; d++ {
    585 		// 9 1hour time slots
    586 		for h := 8; h < 17; h++ {
    587 			// convert to read/compare in UTC from db
    588 			startTime := timeIt.Add(time.Hour * time.Duration((d)*24+h))
    589 			endTime := timeIt.Add(time.Hour * time.Duration((d)*24+h+1))
    590 			start := startTime.UTC().Format(sqlDateFmt)
    591 			end := endTime.UTC().Format(sqlDateFmt)
    592 			avi, err := s.available(start, end)
    593 			if err != nil {
    594 				log.Printf("Error getting availability on day '%d' hour '%d'\n", d, h)
    595 				return
    596 			}
    597 			if avi {
    598 				// Make two columns, for the date/time and the command
    599 				tableData.Rows = append(tableData.Rows, []string{
    600 					fmt.Sprintf("%s - %s", startTime.Format("Mon 02.01. 15:04"), endTime.Format("15:04")),
    601 					fmt.Sprintf("%s", startTime.Format("2006-01-02-15")),
    602 				})
    603 			}
    604 		}
    605 	}
    606 
    607 	if strings.Contains(userAgent, "curl") {
    608 		w.Header().Set("Content-Type", "text/plain")
    609 		io.WriteString(w, fmt.Sprintf("Serving week %d of year %d\n", week, year))
    610 		io.WriteString(w, fmt.Sprintf("Back to calendar: 'curl %s/week/%d/%d'\n", s.config.HiveDavHost, year, week))
    611 
    612 		pt := prettytable.New(tableData.TableHead)
    613 		for _, r := range tableData.Rows {
    614 			// convert to slice of interface
    615 			// https://go.dev/doc/faq#convert_slice_of_interface
    616 			row := make([]interface{}, len(r))
    617 
    618 			// use the date/time column as is
    619 			row[0] = r[0]
    620 			// create the booking cmd
    621 			row[1] = fmt.Sprintf("curl %s/book/%s -F 'mail=' -F 'msg='", s.config.HiveDavHost, r[1])
    622 
    623 			// unpack the string array into row arguments
    624 			pt.AddRow(row...)
    625 		}
    626 
    627 		io.WriteString(w, fmt.Sprint(pt))
    628 		io.WriteString(w, fmt.Sprintf("HiveDAV %s 🍯\n", hivedavVersion))
    629 	} else {
    630 		w.Header().Set("Content-Type", "text/html")
    631 
    632 		tmpl, err := template.New("listcubicles.html").Funcs(funcMap).ParseFiles("./templates/listcubicles.html")
    633 		if err != nil {
    634 			log.Printf("Error parsing html template: %v\n", err)
    635 			http.Error(w, err.Error(), http.StatusInternalServerError)
    636 			return
    637 		}
    638 		err = tmpl.Execute(w, tableData)
    639 		if err != nil {
    640 			log.Printf("Error executing html template: %v\n", err)
    641 			http.Error(w, err.Error(), http.StatusInternalServerError)
    642 			return
    643 		}
    644 	}
    645 }
    646 
    647 func (s *Server) CubicleForm(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
    648 	dtstart := ps.ByName("dtstart")
    649 	dtStartPathTimeLocal, err := time.ParseInLocation("2006-01-02-15", dtstart, time.Local)
    650 
    651 	if err != nil {
    652 		log.Printf("Error parsing booking dtstart: %v\n", err)
    653 		http.Error(w, err.Error(), http.StatusInternalServerError)
    654 		return
    655 	}
    656 
    657 	icsData := CubicleFormData{
    658 		DtstartInput: dtStartPathTimeLocal.Format("2006-01-02T15:04"),
    659 		Dtstart:      dtstart,
    660 		Version:      hivedavVersion,
    661 	}
    662 
    663 	w.Header().Set("Content-Type", "text/html")
    664 
    665 	tmpl, err := template.New("cubicleform.html").Funcs(funcMap).ParseFiles("./templates/cubicleform.html")
    666 	if err != nil {
    667 		log.Printf("Error parsing html template: %v\n", err)
    668 		http.Error(w, err.Error(), http.StatusInternalServerError)
    669 		return
    670 	}
    671 	err = tmpl.Execute(w, icsData)
    672 	if err != nil {
    673 		log.Printf("Error executing html template: %v\n", err)
    674 		http.Error(w, err.Error(), http.StatusInternalServerError)
    675 		return
    676 	}
    677 }
    678 
    679 func (s *Server) BookCubicle(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
    680 	dtStartPath := ps.ByName("dtstart")
    681 
    682 	//TODO: replace Local TZ by config setting
    683 	//TODO: remove Year from the input format
    684 	dtStartPathTimeLocal, err := time.ParseInLocation("2006-01-02-15", dtStartPath, time.Local)
    685 	if err != nil {
    686 		log.Printf("Error parsing booking dtstart from request path: %v\n", err)
    687 		http.Error(w, err.Error(), http.StatusInternalServerError)
    688 		return
    689 	}
    690 	log.Printf("dtstart booking request (local time): %v\n", dtStartPathTimeLocal)
    691 
    692 	// total of maxMemory bytes of its file parts are stored in memory
    693 	// with the remainder stored on disk in temporary files
    694 	// https://pkg.go.dev/net/http#Request.ParseMultipartForm
    695 	err = req.ParseMultipartForm(256)
    696 	if err != nil {
    697 		log.Printf("ParseMultipartForm err: %v\n", err)
    698 		http.Error(w, err.Error(), http.StatusInternalServerError)
    699 		return
    700 	}
    701 
    702 	dtstart := dtStartPathTimeLocal
    703 	inputDateTimeFmt := "2006-01-02T15:04"
    704 	dtStartInput := req.FormValue("dtstart")
    705 	if dtStartInput != "" {
    706 		// TODO: replace local time by timezone specified in config file
    707 		dtStartInputTime, err := time.ParseInLocation(inputDateTimeFmt, dtStartInput, time.Local)
    708 		if err != nil {
    709 			log.Printf("Error parsing booking dtstart from user input: %v\n", err)
    710 			http.Error(w, err.Error(), http.StatusInternalServerError)
    711 			return
    712 		}
    713 
    714 		// prefer time time chosen by the user (html input field)
    715 		if dtStartPathTimeLocal != dtStartInputTime {
    716 			dtstart = dtStartInputTime
    717 			log.Printf("Preferring user input dtstart '%v' from the form\n", dtStartInput)
    718 		}
    719 	}
    720 
    721 	log.Printf("dtStartInput: %v\n", dtStartInput)
    722 	log.Printf("dtStartPath: %v\n", dtStartPath)
    723 	log.Printf("dtstart booking request (local time): %v\n", dtstart)
    724 
    725 	mail := req.FormValue("mail")
    726 	//msg := req.FormValue("msg")
    727 	msg := html.UnescapeString(req.FormValue("msg"))
    728 
    729 	if mail == "" || msg == "" {
    730 		errorMsg := fmt.Sprintf("Mail and msg required, try again: 'curl %s/book/%s -F 'mail=' -F 'msg='\n", s.config.HiveDavHost, dtstart.Format("2006-01-02-15"))
    731 		http.Error(w, errorMsg, http.StatusBadRequest)
    732 		return
    733 	}
    734 
    735 	// convert to read/compare in UTC from db
    736 	sqlDateFmt := "2006-01-02 15:04:00"
    737 	start := dtstart.UTC().Format(sqlDateFmt)
    738 	end := dtstart.Add(time.Hour).UTC().Format(sqlDateFmt)
    739 	avi, err := s.available(start, end)
    740 	if err != nil {
    741 		log.Printf("Error getting availability on '%s'\n", start)
    742 		return
    743 	}
    744 
    745 	// check if the slot is still available
    746 	if !avi {
    747 		log.Printf("Duplicate booking detected for requestor: %s\n", mail)
    748 		http.Error(w, fmt.Sprintf("This cubicle is already booked. Choose a free slot: 'curl %s/list'\n", s.config.HiveDavHost), http.StatusBadRequest)
    749 		return
    750 	}
    751 
    752 	err = s.sendMail(mail, msg, dtstart)
    753 	if err != nil {
    754 		log.Printf("Error occured while sending mail: %v\n", err)
    755 		http.Error(w, err.Error(), http.StatusInternalServerError)
    756 		return
    757 	}
    758 
    759 	// update availability
    760 	startTimeUtc := dtstart.UTC()
    761 	endTimeUtc := dtstart.Add(time.Hour).UTC()
    762 	if _, err = s.db.Exec("INSERT INTO availability VALUES(NULL, DATETIME(?), DATETIME(?), false);", startTimeUtc, endTimeUtc); err != nil {
    763 		log.Printf("Error updating availability database with new booking: %v\n", err)
    764 		http.Error(w, err.Error(), http.StatusInternalServerError)
    765 		return
    766 	}
    767 
    768 	zone, _ := dtstart.Zone()
    769 	io.WriteString(w, fmt.Sprintf("Thank you for booking on %s %s\n", dtstart.Format("02 Jan 2006 15:04"), zone))
    770 
    771 	return
    772 }
    773 
    774 // book a time slot in the calendar
    775 func (s *Server) sendMail(recepient string, msg string, dtstart time.Time) error {
    776 	_, err := netmail.ParseAddress(recepient)
    777 	if err != nil {
    778 		return err
    779 	}
    780 
    781 	e := email.NewEmail()
    782 
    783 	e.From = s.config.SmtpUser
    784 	e.To = []string{
    785 		s.config.CaldavUser,
    786 		recepient,
    787 	}
    788 	e.Subject = s.config.BookingSummary
    789 	e.Text = []byte(msg)
    790 
    791 	// parse ics booking template
    792 	icsTmpl, err := template.ParseFiles("templates/booking.ics")
    793 	if err != nil {
    794 		return err
    795 	}
    796 
    797 	// create ics booking from template
    798 	tmpFilePath := fmt.Sprintf("/tmp/%s.ics", dtstart.Format("2006-01-02-15"))
    799 	icsFile, err := os.Create(tmpFilePath)
    800 	if err != nil {
    801 		return err
    802 	}
    803 	defer icsFile.Close()
    804 	defer os.Remove(tmpFilePath)
    805 
    806 	uuid, _ := genUid(12)
    807 
    808 	// get timezone and offset
    809 	// TODO: This will probably have to change for winter (standard) time
    810 	// TODO: Check the 1/2h timezones
    811 	zone, offsetSec := dtstart.Zone()
    812 	tzdbZone := tzdb.GetTzdbFromLocation(zone)
    813 	offsetHours := offsetSec / 60 / 60
    814 
    815 	var (
    816 		dstTzOffsetTo        string
    817 		dstTzOffsetFrom      string
    818 		standardTzOffsetTo   string
    819 		standardTzOffsetFrom string
    820 	)
    821 
    822 	if dtstart.IsDST() {
    823 		dstTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours))
    824 		standardTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours))
    825 		dstTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours - 1))
    826 		standardTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours - 1))
    827 	} else {
    828 		// Winter/Standard time
    829 		standardTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours))
    830 		dstTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours))
    831 		standardTzOffsetFrom = fmt.Sprintf("%+03d00", (offsetHours + 1))
    832 		dstTzOffsetTo = fmt.Sprintf("%+03d00", (offsetHours + 1))
    833 	}
    834 
    835 	foldedMsg := fold(msg)
    836 
    837 	icsData := IcsData{
    838 		Prodid:               fmt.Sprintf("-//HiveDAV//%s//EN", hivedavVersion),
    839 		Uid:                  strings.ToUpper(uuid),
    840 		Timestamp:            time.Now().Format("20060102T150405Z"),
    841 		Dtstart:              dtstart.Format("20060102T150405"),
    842 		Dtend:                dtstart.Add(time.Hour).Format("20060102T150405"),
    843 		Timezone:             tzdbZone,
    844 		StandardTzOffsetFrom: standardTzOffsetFrom,
    845 		StandardTzOffsetTo:   standardTzOffsetTo,
    846 		DstTzOffsetFrom:      dstTzOffsetFrom,
    847 		DstTzOffsetTo:        dstTzOffsetTo,
    848 		Summary:              s.config.BookingSummary,
    849 		Reminder:             s.config.BookingReminder,
    850 		Description:          foldedMsg,
    851 		Location:             fmt.Sprintf("%s/%s", s.config.BookingLocation, strings.ToUpper(uuid)),
    852 		Organizer:            s.config.CaldavUser,
    853 		Attendee:             recepient,
    854 	}
    855 
    856 	log.Printf("foldedMsg: %s\n", foldedMsg)
    857 	log.Printf("Description: %s\n", icsData.Description)
    858 
    859 	err = icsTmpl.Execute(icsFile, icsData)
    860 	if err != nil {
    861 		return err
    862 	}
    863 
    864 	e.AttachFile(tmpFilePath)
    865 	lastSlashIndex := strings.LastIndex(tmpFilePath, "/")
    866 	var tmpFileName string
    867 	if lastSlashIndex != -1 {
    868 		tmpFileName = tmpFilePath[lastSlashIndex+1:]
    869 	}
    870 	// https://datatracker.ietf.org/doc/html/rfc2447#section-2.4
    871 	e.Attachments[0].ContentType = fmt.Sprintf("text/calendar; charset=utf-8; method=REQUEST; name=%s", tmpFileName)
    872 	//e.Attachments[0].ContentType = "text/calendar; charset=utf-8; method=REQUEST"
    873 	//fmt.Println(e.Attachments[0].ContentType)
    874 	smtpServer := fmt.Sprintf("%s:%d", s.config.SmtpHost, s.config.SmtpPort)
    875 	auth := smtp.PlainAuth("", s.config.SmtpUser, s.config.SmtpPassword, s.config.SmtpHost)
    876 
    877 	if s.config.SmtpStartTls {
    878 		tlsConfig := tls.Config{
    879 			InsecureSkipVerify: true,
    880 		}
    881 		err = e.SendWithStartTLS(smtpServer, auth, &tlsConfig)
    882 	} else {
    883 		err = e.SendWithStartTLS(smtpServer, auth, nil)
    884 	}
    885 
    886 	if err != nil {
    887 		return err
    888 	}
    889 
    890 	return nil
    891 }
    892 
    893 // simple string to rune and hex conversion in Python
    894 // i=ord("\0"); hex(i)
    895 func fold(s string) string {
    896 	// convert the string unicode code points
    897 	runeStr := []rune(s)
    898 
    899 	// create buffer for escaped result TEXT
    900 	buf := make([]rune, 0)
    901 
    902 	// bufl is the current buffer size incl. \0
    903 	var bufl = 0
    904 	// i is the iterator in s
    905 	var i = 0
    906 
    907 	// escch is the char to be escaped,
    908 	// only written when esc=true
    909 	escch := rune(0x0) // \0
    910 	esc := false
    911 
    912 	for (i < len(runeStr)) || esc {
    913 		buf = append(buf, rune(0x0))
    914 		bufl++
    915 
    916 		if (bufl > 1) && ((bufl % 77) == 0) {
    917 			// break lines after 75 chars
    918 			// split between any two characters by inserting a CRLF
    919 			// immediately followed by a white space character
    920 			buf[bufl-1] = rune(0xa) // newline '\n'
    921 			escch = rune(0x20)      // whitespace ' '
    922 
    923 			esc = true
    924 			continue
    925 		}
    926 
    927 		if esc {
    928 			// only escape char, do not advance iterator i
    929 			buf[bufl-1] = escch
    930 			esc = false
    931 		} else {
    932 			// escape characters
    933 			// https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11
    934 			switch runeStr[i] {
    935 			case rune(0x5c): // backslash '\'
    936 				buf[bufl-1] = rune(0x5c)
    937 				escch = rune(0x5c)
    938 				esc = true
    939 				break
    940 			case rune(0x3b): // semicolon ';'
    941 				buf[bufl-1] = rune(0x5c)
    942 				escch = rune(0x3b)
    943 				esc = true
    944 				break
    945 			case rune(0x2c): // comma ','
    946 				buf[bufl-1] = rune(0x5c)
    947 				escch = rune(0x2c)
    948 				esc = true
    949 				break
    950 			case rune(0xa): // newline '\n'
    951 				buf[bufl-1] = rune(0x5c) // backslash '\'
    952 				escch = rune(0x6e)       // literal 'n'
    953 				esc = true
    954 				break
    955 			case rune(0xd): // carriage return '\r'
    956 				buf[bufl-1] = rune(0x20) // whitespace ' '
    957 				break
    958 			default:
    959 				// write regular character from runeStr
    960 				buf[bufl-1] = runeStr[i]
    961 				break
    962 			}
    963 			i++
    964 		}
    965 	}
    966 
    967 	return string(buf)
    968 }
    969 
    970 // https://datatracker.ietf.org/doc/html/rfc7986#section-5.3
    971 func genUid(n int) (string, error) {
    972 	bytes := make([]byte, n)
    973 	if _, err := rand.Read(bytes); err != nil {
    974 		return "", err
    975 	}
    976 	return hex.EncodeToString(bytes), nil
    977 }