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 }