oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
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.
18type 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.
25type bookSlot struct {
26 StartUTC, EndUTC int64
27 Label string
28}
29
30// bookPageData is what book_page.html receives. Never a BookingView.
31type 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.
54func (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.
71func (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}.
139func (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.
155func (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).
209func (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