| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "time" |
| 8 | "unicode/utf8" |
| 9 | |
| 10 | "github.com/go-chi/chi/v5" |
| 11 | ) |
| 12 | |
| 13 | // The public page (booking spec §5.1–5.2): a stranger picks a day, then |
| 14 | // a slot, then types a name and an address, and the slot is HELD. Two |
| 15 | // POSTs, no script anywhere in the path. |
| 16 | |
| 17 | // bookDay is one day the page offers, in the booker's zone. |
| 18 | type bookDay struct { |
| 19 | Date string // YYYY-MM-DD in the booker's zone |
| 20 | Label string |
| 21 | Count int |
| 22 | } |
| 23 | |
| 24 | // bookSlot is one offered start, in the booker's zone. |
| 25 | type bookSlot struct { |
| 26 | StartUTC, EndUTC int64 |
| 27 | Label string |
| 28 | } |
| 29 | |
| 30 | // bookPageData is what book_page.html receives. Never a BookingView. |
| 31 | type bookPageData struct { |
| 32 | Handle, Code string |
| 33 | Title, Description string |
| 34 | HostName string |
| 35 | DurationMin int |
| 36 | Zone string |
| 37 | Zones []string |
| 38 | ZoneChosen bool |
| 39 | Days []bookDay |
| 40 | Day string |
| 41 | DayLabel string |
| 42 | Slots []bookSlot |
| 43 | Picked *bookSlot |
| 44 | Name, Address, Note string |
| 45 | Error, ErrorField string |
| 46 | Gone bool // "That time has just gone" |
| 47 | Path string |
| 48 | RescheduleToken string // set on the reschedule picker |
| 49 | Rev int |
| 50 | } |
| 51 | |
| 52 | // publicPage resolves the URL to a published page and its host, or the |
| 53 | // router's own 404. |
| 54 | func (h *handlers) publicPage(w http.ResponseWriter, r *http.Request) (BookingPageView, Person, bool) { |
| 55 | page, host, err := h.store.PublicBookingPage(r.Context(), chi.URLParam(r, "handle"), chi.URLParam(r, "code")) |
| 56 | if NotFound(err) { |
| 57 | notFoundLikeTheRouter(w, r) |
| 58 | return page, host, false |
| 59 | } |
| 60 | if err != nil { |
| 61 | h.fail(w, r, "public booking page", err) |
| 62 | return page, host, false |
| 63 | } |
| 64 | return page, host, true |
| 65 | } |
| 66 | |
| 67 | // picker computes what the page shows: the days with anything free, or |
| 68 | // one day's slots. from/to are the horizon; excludeEventID and |
| 69 | // durationMin are for the reschedule picker. Errors are already |
| 70 | // answered (503) when ok is false. |
| 71 | func (h *handlers) picker(w http.ResponseWriter, r *http.Request, page BookingPageView, host Person, zone, day string, excludeEventID int64, durationMin int) (bookPageData, bool) { |
| 72 | d := bookPageData{ |
| 73 | Title: page.Title, Description: page.Description, HostName: hostNameOf(host), |
| 74 | DurationMin: page.DurationMin, Zone: zone, Zones: zoneChoices(host), Day: day, Path: r.URL.Path, |
| 75 | } |
| 76 | release, ok := acquireSlots() |
| 77 | if !ok { |
| 78 | busyNow(w) |
| 79 | return d, false |
| 80 | } |
| 81 | defer release() |
| 82 | |
| 83 | now := time.Now() |
| 84 | bookerLoc, err := Zone(zone) |
| 85 | if err != nil { |
| 86 | h.fail(w, r, "booker zone", err) |
| 87 | return d, false |
| 88 | } |
| 89 | hostLoc, _ := Zone(host.Zone) |
| 90 | latest := now.In(hostLoc).AddDate(0, 0, page.HorizonDays) |
| 91 | |
| 92 | from, to := now, latest |
| 93 | if day != "" { |
| 94 | t, err := time.ParseInLocation(LayoutDate, day, bookerLoc) |
| 95 | if err != nil || t.Before(now.In(bookerLoc).AddDate(0, 0, -1)) || t.After(latest) { |
| 96 | notFoundLikeTheRouter(w, r) |
| 97 | return d, false |
| 98 | } |
| 99 | // The booker's day, widened a day either side in the host's |
| 100 | // zone: a slot on the booker's Sunday may be the host's Monday. |
| 101 | from, to = t.AddDate(0, 0, -1), t.AddDate(0, 0, 2) |
| 102 | d.DayLabel = t.Format("Monday 2 January") |
| 103 | } |
| 104 | slots, err := h.store.FreeSlots(r.Context(), page, host, now, from, to, excludeEventID, durationMin) |
| 105 | if errors.Is(err, ErrIncompleteExpansion) { |
| 106 | w.Header().Set("Retry-After", "60") |
| 107 | http.Error(w, "This page cannot work out free time just now. Try again shortly.", http.StatusServiceUnavailable) |
| 108 | return d, false |
| 109 | } |
| 110 | if err != nil { |
| 111 | h.fail(w, r, "free slots", err) |
| 112 | return d, false |
| 113 | } |
| 114 | |
| 115 | counts := map[string]int{} |
| 116 | var order []string |
| 117 | for _, s := range slots { |
| 118 | local := time.Unix(s.StartUTC, 0).In(bookerLoc) |
| 119 | date := local.Format(LayoutDate) |
| 120 | if day == "" { |
| 121 | if counts[date] == 0 { |
| 122 | order = append(order, date) |
| 123 | } |
| 124 | counts[date]++ |
| 125 | continue |
| 126 | } |
| 127 | if date == day { |
| 128 | d.Slots = append(d.Slots, bookSlot{StartUTC: s.StartUTC, EndUTC: s.EndUTC, Label: local.Format("15:04")}) |
| 129 | } |
| 130 | } |
| 131 | for _, date := range order { |
| 132 | t, _ := time.ParseInLocation(LayoutDate, date, bookerLoc) |
| 133 | d.Days = append(d.Days, bookDay{Date: date, Label: t.Format("Monday 2 January"), Count: counts[date]}) |
| 134 | } |
| 135 | return d, true |
| 136 | } |
| 137 | |
| 138 | // bookPage is GET /book/{handle}/{code}. |
| 139 | func (h *handlers) bookPage(w http.ResponseWriter, r *http.Request) { |
| 140 | page, host, ok := h.publicPage(w, r) |
| 141 | if !ok { |
| 142 | return |
| 143 | } |
| 144 | zone, chosen := pickZone(r, host) |
| 145 | d, ok := h.picker(w, r, page, host, zone, strings.TrimSpace(r.URL.Query().Get("day")), 0, 0) |
| 146 | if !ok { |
| 147 | return |
| 148 | } |
| 149 | d.Handle, d.Code, d.ZoneChosen = chi.URLParam(r, "handle"), chi.URLParam(r, "code"), chosen |
| 150 | render(w, "book_page", h.page(r, page.Title, d)) |
| 151 | } |
| 152 | |
| 153 | // bookPost is POST /book/{handle}/{code}: the slot pick (no name yet) |
| 154 | // or the hold. |
| 155 | func (h *handlers) bookPost(w http.ResponseWriter, r *http.Request) { |
| 156 | page, host, ok := h.publicPage(w, r) |
| 157 | if !ok { |
| 158 | return |
| 159 | } |
| 160 | r.Body = http.MaxBytesReader(w, r.Body, 16<<10) |
| 161 | if err := r.ParseForm(); err != nil { |
| 162 | http.Error(w, "That form was too large.", http.StatusUnprocessableEntity) |
| 163 | return |
| 164 | } |
| 165 | zone, chosen := pickZone(r, host) |
| 166 | start, okStart := parseUnix(r.FormValue("start")) |
| 167 | if !okStart { |
| 168 | http.Redirect(w, r, r.URL.Path+"?zone="+zone, http.StatusSeeOther) |
| 169 | return |
| 170 | } |
| 171 | bookerLoc, _ := Zone(zone) |
| 172 | day := time.Unix(start, 0).In(bookerLoc).Format(LayoutDate) |
| 173 | if strings.TrimSpace(r.FormValue("name")) != "" || r.FormValue("address") != "" { |
| 174 | // The hold. No picker first: the store checks the caps and the |
| 175 | // slot itself, behind the write lock, and a page is computed |
| 176 | // only if it has to say "that time has just gone". |
| 177 | end := start + int64(page.DurationMin)*60 |
| 178 | picked := bookSlot{StartUTC: start, EndUTC: end, Label: time.Unix(start, 0).In(bookerLoc).Format("15:04")} |
| 179 | d := bookPageData{Title: page.Title, Description: page.Description, HostName: hostNameOf(host), |
| 180 | DurationMin: page.DurationMin, Zone: zone, Zones: zoneChoices(host), Day: day, Path: r.URL.Path, |
| 181 | Handle: chi.URLParam(r, "handle"), Code: chi.URLParam(r, "code"), ZoneChosen: chosen, Picked: &picked} |
| 182 | if t, err := time.ParseInLocation(LayoutDate, day, bookerLoc); err == nil { |
| 183 | d.DayLabel = t.Format("Monday 2 January") |
| 184 | } |
| 185 | h.hold(w, r, page, host, d, zone) |
| 186 | return |
| 187 | } |
| 188 | d, ok := h.picker(w, r, page, host, zone, day, 0, 0) |
| 189 | if !ok { |
| 190 | return |
| 191 | } |
| 192 | d.Handle, d.Code, d.ZoneChosen = chi.URLParam(r, "handle"), chi.URLParam(r, "code"), chosen |
| 193 | for i := range d.Slots { |
| 194 | if d.Slots[i].StartUTC == start { |
| 195 | d.Picked = &d.Slots[i] |
| 196 | } |
| 197 | } |
| 198 | if d.Picked == nil { |
| 199 | d.Gone = true |
| 200 | w.WriteHeader(http.StatusConflict) |
| 201 | render(w, "book_page", h.page(r, page.Title, d)) |
| 202 | return |
| 203 | } |
| 204 | // The slot pick: show the details form beneath it. |
| 205 | render(w, "book_page", h.page(r, page.Title, d)) |
| 206 | } |
| 207 | |
| 208 | // hold validates the details and makes the hold (§5.2). |
| 209 | func (h *handlers) hold(w http.ResponseWriter, r *http.Request, page BookingPageView, host Person, d bookPageData, zone string) { |
| 210 | // A browser submits a textarea's line breaks as CRLF. Normalised |
| 211 | // to LF before anything is measured or refused, so two paragraphs |
| 212 | // are two paragraphs and not a control character. |
| 213 | d.Name = strings.TrimSpace(strings.ReplaceAll(r.FormValue("name"), "\r", "")) |
| 214 | d.Address = normalizeAddress(r.FormValue("address")) |
| 215 | d.Note = strings.TrimSpace(strings.ReplaceAll(r.FormValue("note"), "\r\n", "\n")) |
| 216 | reject := func(field, msg string) { |
| 217 | d.Error, d.ErrorField = msg, field |
| 218 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 219 | render(w, "book_page", h.page(r, page.Title, d)) |
| 220 | } |
| 221 | switch { |
| 222 | case d.Name == "" || utf8.RuneCountInString(d.Name) > MaxBookerNameRunes || len(d.Name) > MaxBookerNameBytes || hasControl(d.Name): |
| 223 | // A line break in a name would reach a mail SUBJECT, and the |
| 224 | // SMTP sender refuses a folded header with an error that quotes |
| 225 | // it. Refused here, where it costs a sentence. |
| 226 | reject("name", "Your name, please — up to sixty characters, on one line.") |
| 227 | return |
| 228 | case !reAddress.MatchString(d.Address) || reAddress.FindString(d.Address) != d.Address: |
| 229 | reject("address", "That does not look like an email address.") |
| 230 | return |
| 231 | case utf8.RuneCountInString(d.Note) > MaxNoteRunes || len(d.Note) > MaxNoteBytes: |
| 232 | reject("note", "Keep the note shorter — a thousand characters is plenty.") |
| 233 | return |
| 234 | case hasControl(strings.ReplaceAll(d.Note, "\n", "")): |
| 235 | reject("note", "The note has characters that cannot be sent.") |
| 236 | return |
| 237 | } |
| 238 | now := time.Now() |
| 239 | // The store's own slot arithmetic runs under the same semaphore as |
| 240 | // the pickers (§4.2): four public computations at once, whoever |
| 241 | // asks for them. |
| 242 | release, ok := acquireSlots() |
| 243 | if !ok { |
| 244 | busyNow(w) |
| 245 | return |
| 246 | } |
| 247 | b, err := h.store.HoldBooking(r.Context(), HoldInput{ |
| 248 | Page: page, Host: host, StartUTC: d.Picked.StartUTC, EndUTC: d.Picked.EndUTC, |
| 249 | Name: d.Name, Address: d.Address, Note: d.Note, BookerZone: zone, IPFP: h.ipFingerprint(r), |
| 250 | }, now) |
| 251 | release() |
| 252 | switch { |
| 253 | case errors.Is(err, ErrOverCap): |
| 254 | w.Header().Set("Retry-After", "1800") |
| 255 | w.WriteHeader(http.StatusTooManyRequests) |
| 256 | render(w, "book_gone", h.page(r, page.Title, map[string]any{"Path": d.Path, "Zone": zone, |
| 257 | "Title": page.Title, "Why": "Too many bookings in progress. Try again later."})) |
| 258 | return |
| 259 | case errors.Is(err, ErrSlotTaken): |
| 260 | // Only now compute the day's page, to say what is still free. |
| 261 | again, ok := h.picker(w, r, page, host, zone, d.Day, 0, 0) |
| 262 | if !ok { |
| 263 | return |
| 264 | } |
| 265 | again.Handle, again.Code, again.ZoneChosen, again.Gone = d.Handle, d.Code, d.ZoneChosen, true |
| 266 | w.WriteHeader(http.StatusConflict) |
| 267 | render(w, "book_page", h.page(r, page.Title, again)) |
| 268 | return |
| 269 | case errors.Is(err, ErrIncompleteExpansion): |
| 270 | w.Header().Set("Retry-After", "60") |
| 271 | http.Error(w, "This page cannot work out free time just now. Try again shortly.", http.StatusServiceUnavailable) |
| 272 | return |
| 273 | case err != nil: |
| 274 | h.fail(w, r, "hold", err) |
| 275 | return |
| 276 | } |
| 277 | // Whatever the mail does, the hold stands (B16). |
| 278 | sent := h.trySendNow(r.Context(), b.ID, now) |
| 279 | render(w, "book_sent", h.page(r, page.Title, map[string]any{ |
| 280 | "Title": page.Title, "Address": d.Address, "Unsure": !sent, |
| 281 | "ExpiresAt": instantWords(b.HoldExpiresAt, zone), "Path": d.Path, "Zone": zone, |
| 282 | })) |
| 283 | } |
| 284 | |