booking: a stranger holds a slot, a button confirms it, and the ICS is one click away
The public page is two POSTs and no script: a slot button posts an instant, the page comes back with the details form beneath it, and the details post makes the hold. The link in the mail opens a page with one button, and the button is a POST — a link that confirmed on GET would be confirmed by every link-scanning mail gateway on earth. A wrong token, a wrong handle and an unknown route are byte-for-byte the same 404. Public templates receive a publicBooking built by one function from a BookingView, never the view or the event, so the host's notes, location and other attendees cannot reach a stranger by accident; a test edits all three after confirmation and reads the manage page, the ICS and the cancelled mail. The ICS is its own writer: METHOD on the calendar, ORGANIZER and ATTENDEE with no CN= (a parameter has quoting rules TEXT escaping does not implement, and a name is not needed for the invitation to work), no URL: (a management capability inside a file that syncs everywhere is delegation, not disclosure). Every send goes through one bounded sender: four in flight, a wait of at most twenty seconds or what is left of the request, ErrMailBusy when full — a deferral, never an attempt. Every public route passes a per-IP token bucket bounded to ten thousand keys, and slot computation sits behind a four-wide semaphore that answers 503 rather than a page computed from less than the whole busy list. A hold whose mail is refused stands: the sender's error is not proof the mail did not go.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
27 files changed,
+1665
−9
.impeccable/hook.cache.json+1 −1internal/calendar/app.go+5 −1internal/calendar/booking_ics.go+51 −0internal/calendar/booking_mail.go+120 −0internal/calendar/booking_public.go+104 −0internal/calendar/handlers.go+6 −1internal/calendar/handlers_booking_manage.go+213 −0internal/calendar/handlers_booking_public.go+256 −0internal/calendar/handlers_booking_util.go+192 −0internal/calendar/mail_bounded.go+69 −1internal/calendar/render.go+1 −0internal/calendar/routes.go+13 −0internal/calendar/static/calendar.css+32 −0internal/calendar/templates/book_gone.html+11 −0internal/calendar/templates/book_hold.html+22 −0internal/calendar/templates/book_manage.html+27 −0internal/calendar/templates/book_page.html+77 −0internal/calendar/templates/book_sent.html+16 −0internal/calendar/templates/mail/cancelled_booker.txt+3 −0internal/calendar/templates/mail/cancelled_host.txt+5 −0internal/calendar/templates/mail/confirmed.txt+7 −0internal/calendar/templates/mail/hold.txt+7 −0internal/calendar/templates/mail/rescheduled_booker.txt+7 −0internal/calendar/templates/mail/rescheduled_host.txt+3 −0internal/calendartest/booking_abuse_test.go+106 −0internal/calendartest/booking_http_test.go+274 −0internal/calendartest/delivery_test.go+37 −5
diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json| index 36356a9..d914b3c 100644 |
| --- a/.impeccable/hook.cache.json |
| +++ b/.impeccable/hook.cache.json |
| @@ -1 +1 @@ |
| -{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788765935241,"files":{"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/calendar.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/layout.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/_chip.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/day.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/week.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/month.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/agenda.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_form.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":3,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_delete.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/calendars.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/share.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import_done.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/settings.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/booking_pages.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/booking_page_form.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/booking.js":{"editCount":1,"findings":[]}}}}} |
| \ No newline at end of file |
| +{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788766399152,"files":{"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/calendar.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/layout.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/_chip.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/day.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/week.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/month.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/agenda.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_form.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":3,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_delete.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/calendars.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/share.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import_done.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/settings.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/booking_pages.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/booking_page_form.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/booking.js":{"editCount":1,"findings":[]},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/book_page.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/book_sent.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/book_hold.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/book_manage.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/book_gone.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}} |
| \ No newline at end of file |
diff --git a/internal/calendar/app.go b/internal/calendar/app.go| index 58ccd29..a3998e9 100644 |
| --- a/internal/calendar/app.go |
| +++ b/internal/calendar/app.go |
| @@ -124,7 +124,11 @@ func New(cfg Config) (*App, error) { |
| mailRecorder: recorder, |
| auth: authPlugin, |
| } |
| - h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer} |
| + // ONE bounded sender for everything the handlers send — reminders, |
| + // booking mail. The recorder stays inside it, so tests still read |
| + // what was sent (booking spec §6). |
| + h := &handlers{store: store, logger: logger, origin: origin, |
| + mailer: newBoundedSender(mailer), gate: newGate()} |
| a.mux = a.routes(h, authPlugin, list, origin) |
| return a, nil |
| } |
diff --git a/internal/calendar/booking_ics.go b/internal/calendar/booking_ics.go| new file mode 100644 |
| index 0000000..75df23e |
| --- /dev/null |
| +++ b/internal/calendar/booking_ics.go |
| @@ -0,0 +1,51 @@ |
| +package calendar |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// WriteBookingICS is the booking's own writer (booking spec §5.8). It |
| +// takes the public view and the event's opened UID — never an |
| +// EventView — so it cannot carry the host's notes, location or other |
| +// attendees. WriteICS is untouched and still says METHOD:PUBLISH for |
| +// calendar exports. |
| +// |
| +// No CN= parameters: a parameter value has quoting rules of its own |
| +// (RFC 5545 §3.2, RFC 6868) that escapeText does not implement, and a |
| +// name is not needed for the invitation to work — SUMMARY names the |
| +// meeting and the addresses name the people. No URL:, because a |
| +// management capability inside a file that syncs everywhere is |
| +// delegation, not disclosure (B6). |
| +func WriteBookingICS(pb publicBooking, uid string, updated time.Time, hostLocal Event) string { |
| + method, status := "REQUEST", "CONFIRMED" |
| + if pb.Cancelled { |
| + method, status = "CANCEL", "CANCELLED" |
| + } |
| + var b strings.Builder |
| + line(&b, "BEGIN:VCALENDAR") |
| + line(&b, "VERSION:2.0") |
| + line(&b, "PRODID:"+ProdID) |
| + line(&b, "CALSCALE:GREGORIAN") |
| + line(&b, "METHOD:"+method) |
| + line(&b, "BEGIN:VEVENT") |
| + if uid == "" { |
| + uid = fmt.Sprintf("%d@calendar.oficina", hostLocal.ID) |
| + } |
| + line(&b, "UID:"+escapeText(uid)) |
| + line(&b, "DTSTAMP:"+utcStamp(updated)) |
| + line(&b, fmt.Sprintf("SEQUENCE:%d", pb.Rev)) |
| + line(&b, "DTSTART;TZID="+hostLocal.TZ+":"+icsDateTime(hostLocal.StartLocal)) |
| + line(&b, "DTEND;TZID="+hostLocal.TZ+":"+icsDateTime(hostLocal.EndLocal)) |
| + line(&b, "SUMMARY:"+escapeText(pb.Title)) |
| + if pb.Description != "" { |
| + line(&b, "DESCRIPTION:"+escapeText(pb.Description)) |
| + } |
| + line(&b, "ORGANIZER:mailto:"+escapeText(pb.HostAddress)) |
| + line(&b, "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:"+escapeText(pb.Address)) |
| + line(&b, "STATUS:"+status) |
| + line(&b, "END:VEVENT") |
| + line(&b, "END:VCALENDAR") |
| + return b.String() |
| +} |
diff --git a/internal/calendar/booking_mail.go b/internal/calendar/booking_mail.go| new file mode 100644 |
| index 0000000..128f0c1 |
| --- /dev/null |
| +++ b/internal/calendar/booking_mail.go |
| @@ -0,0 +1,120 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "embed" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "text/template" |
| + "time" |
| +) |
| + |
| +// Booking mail (booking spec §6): four kinds, plain text, rendered at |
| +// send time from the booking's CURRENT state and the public view — |
| +// nothing a booker may not see reaches a template here. |
| + |
| +//go:embed templates/mail/*.txt |
| +var mailFS embed.FS |
| + |
| +var mailTemplates = template.Must(template.ParseFS(mailFS, "templates/mail/*.txt")) |
| + |
| +// mailData is what every mail template receives. |
| +type mailData struct { |
| + publicBooking |
| + ManageURL string |
| + EventURL string |
| + // Stale is set on a "confirmed" mail whose booking is no longer |
| + // confirmed when it finally renders, so the first line says so |
| + // rather than announcing a meeting that was since cancelled (B19). |
| + Stale bool |
| +} |
| + |
| +// renderBookingMail produces the subject and body for one queue row. |
| +func (h *handlers) renderBookingMail(row BookingMail, v BookingView, now time.Time) (to, subject, body string, err error) { |
| + pb := publicOf(v, now) |
| + d := mailData{publicBooking: pb, EventURL: h.eventURL(v.EventID)} |
| + if v.Token != "" { |
| + d.ManageURL = h.manageURL(v.Token) |
| + } |
| + if row.Recipient == ToHost { |
| + to = v.Host.Address |
| + } else { |
| + to = v.Address |
| + } |
| + switch row.Kind { |
| + case MailHold: |
| + subject = "Confirm your booking: " + pb.Title |
| + case MailConfirmed: |
| + subject = pb.Name + " booked " + pb.Title |
| + d.Stale = !pb.Live |
| + case MailRescheduled: |
| + subject = "Moved: " + pb.Title |
| + case MailCancelled: |
| + subject = "Cancelled: " + pb.Title |
| + default: |
| + return "", "", "", fmt.Errorf("calendar: unknown mail kind") |
| + } |
| + name := row.Kind + "_" + row.Recipient + ".txt" |
| + if t := mailTemplates.Lookup(name); t == nil { |
| + name = row.Kind + ".txt" |
| + } |
| + var b strings.Builder |
| + if err := mailTemplates.ExecuteTemplate(&b, name, d); err != nil { |
| + return "", "", "", fmt.Errorf("calendar: render mail %s: %w", row.Kind, err) |
| + } |
| + return to, subject, b.String(), nil |
| +} |
| + |
| +// sendBookingMail delivers one claimed row and records the outcome: |
| +// acknowledged, deferred (the sender was busy — no attempt), or failed. |
| +// Nothing is concluded from an error except "try again later" (B16). |
| +func (h *handlers) sendBookingMail(ctx context.Context, row BookingMail, now time.Time) error { |
| + v, err := h.store.BookingByID(ctx, row.BookingID, true) |
| + if err != nil { |
| + return err |
| + } |
| + to, subject, body, err := h.renderBookingMail(row, v, now) |
| + if err != nil { |
| + return err |
| + } |
| + sendCtx, cancel := context.WithTimeout(ctx, maxSendWait) |
| + defer cancel() |
| + err = h.mailer.Send(sendCtx, to, subject, body) |
| + switch { |
| + case err == nil: |
| + return h.store.MarkBookingMailSent(ctx, row.ID, row.ClaimID, now.Unix()) |
| + case errors.Is(err, ErrMailBusy): |
| + return h.store.DeferBookingMail(ctx, row.ID, row.ClaimID, now.Unix()) |
| + default: |
| + gaveUp, merr := h.store.MarkBookingMailFailed(ctx, row.ID, row.ClaimID) |
| + if merr != nil { |
| + return merr |
| + } |
| + code := "retry" |
| + if gaveUp { |
| + code = "gave_up" |
| + } |
| + // No address, no token, no title: a stage and a code. |
| + h.logger.Error("calendar: booking mail", "stage", "booking.mail", "code", code, "err", scrubErr(err)) |
| + return err |
| + } |
| +} |
| + |
| +// trySendNow is the immediate attempt after a transition: claim and |
| +// send every row the booking owes, once each. Failures are the drain's |
| +// to retry; the caller learns only whether everything went. |
| +func (h *handlers) trySendNow(ctx context.Context, bookingID int64, now time.Time) (allSent bool) { |
| + allSent = true |
| + for { |
| + row, ok, err := h.store.ClaimBookingMail(ctx, now.Unix()) |
| + if err != nil || !ok { |
| + return allSent && err == nil |
| + } |
| + if err := h.sendBookingMail(ctx, row, now); err != nil { |
| + if row.BookingID == bookingID { |
| + allSent = false |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/calendar/booking_public.go b/internal/calendar/booking_public.go| new file mode 100644 |
| index 0000000..12d3e23 |
| --- /dev/null |
| +++ b/internal/calendar/booking_public.go |
| @@ -0,0 +1,104 @@ |
| +package calendar |
| + |
| +import ( |
| + "strconv" |
| + "time" |
| +) |
| + |
| +// publicBooking is EVERYTHING a public page, a booker's mail or the ICS |
| +// may know about a booking (booking spec §5.5). Built from a |
| +// BookingView by exactly one function, and public templates receive |
| +// this and nothing else — never a BookingView, never an Event — so the |
| +// host's notes, location and attendee list cannot reach a stranger by |
| +// accident. |
| +type publicBooking struct { |
| + Title, Description string |
| + |
| + // The event's times, as instants and as wall clock in the booker's |
| + // zone (When) and the host's (HostWhen) when they differ. |
| + StartUTC, EndUTC int64 |
| + When, HostWhen string |
| + Zone, HostZone string |
| + Rev int |
| + |
| + HostName, HostAddress string |
| + Name, Address string |
| + |
| + State string |
| + CancelledBy string |
| + Live bool // confirmed and the event stands |
| + Held bool // held and not lapsed |
| + Expired bool |
| + Cancelled bool |
| + ExpiresAt string // booker zone, for a held booking |
| + |
| + // Token is set only on a page that already knows it (it came in |
| + // the URL), for building the links on that page. |
| + Token string |
| +} |
| + |
| +// publicOf is the one door. now decides whether a hold has lapsed. |
| +func publicOf(v BookingView, now time.Time) publicBooking { |
| + pb := publicBooking{ |
| + Title: v.Title, Description: v.Description, |
| + StartUTC: v.Event.StartUTC, EndUTC: v.Event.EndUTC, |
| + Zone: v.BookerZone, HostZone: v.Event.TZ, Rev: v.Event.Sequence, |
| + HostName: v.HostName, HostAddress: v.Host.Address, |
| + Name: v.Name, Address: v.Address, |
| + State: v.State, CancelledBy: v.CancelledBy, |
| + } |
| + pb.When = spanWords(v.Event.StartUTC, v.Event.EndUTC, v.BookerZone) |
| + if v.Event.TZ != "" && v.Event.TZ != v.BookerZone { |
| + pb.HostWhen = spanWords(v.Event.StartUTC, v.Event.EndUTC, v.Event.TZ) |
| + } |
| + pb.Cancelled = v.Cancelled() |
| + pb.Live = v.Live() |
| + switch v.State { |
| + case BookingHeld: |
| + if now.Unix() < v.HoldExpiresAt && !pb.Cancelled { |
| + pb.Held = true |
| + } else { |
| + pb.Expired = true |
| + } |
| + pb.ExpiresAt = instantWords(v.HoldExpiresAt, v.BookerZone) |
| + case BookingExpired: |
| + pb.Expired = true |
| + } |
| + if pb.CancelledBy == "" && pb.Cancelled { |
| + pb.CancelledBy = CancelledByHost |
| + } |
| + return pb |
| +} |
| + |
| +// spanWords is "Monday 7 September 2026, 09:00–09:30 (Europe/Dublin)". |
| +func spanWords(startUTC, endUTC int64, zone string) string { |
| + s, err := InZone(startUTC, zone) |
| + if err != nil { |
| + return "" |
| + } |
| + e, err := InZone(endUTC, zone) |
| + if err != nil { |
| + return "" |
| + } |
| + out := s.Format("Monday 2 January 2006, 15:04") |
| + if e.Format("2006-01-02") == s.Format("2006-01-02") { |
| + out += "–" + e.Format("15:04") |
| + } else { |
| + out += " – " + e.Format("Monday 2 January 2006, 15:04") |
| + } |
| + return out + " (" + zone + ")" |
| +} |
| + |
| +func instantWords(unix int64, zone string) string { |
| + t, err := InZone(unix, zone) |
| + if err != nil { |
| + return "" |
| + } |
| + return t.Format("15:04 on Monday 2 January") + " (" + zone + ")" |
| +} |
| + |
| +// Link helpers, so templates never assemble a URL. |
| +func (h *handlers) manageURL(token string) string { return h.origin + "/book/m/" + token } |
| +func (h *handlers) eventURL(id int64) string { |
| + return h.origin + "/events/" + strconv.FormatInt(id, 10) |
| +} |
diff --git a/internal/calendar/handlers.go b/internal/calendar/handlers.go| index fa66a94..a26b97f 100644 |
| --- a/internal/calendar/handlers.go |
| +++ b/internal/calendar/handlers.go |
| @@ -18,8 +18,13 @@ type handlers struct { |
| // calendar subscription needs. |
| origin string |
| - // mailer sends reminders. The same sender sign-in uses. |
| + // mailer sends reminders. The same sender sign-in uses, wrapped |
| + // once in the bounded sender so every send is capped the same way. |
| mailer mail.Sender |
| + |
| + // gate is the per-IP token bucket every public booking route |
| + // passes through. One per app; memory only. |
| + gate *gate |
| } |
| // secure reports whether cookies this app sets should carry Secure. It |
diff --git a/internal/calendar/handlers_booking_manage.go b/internal/calendar/handlers_booking_manage.go| new file mode 100644 |
| index 0000000..07ca1d2 |
| --- /dev/null |
| +++ b/internal/calendar/handlers_booking_manage.go |
| @@ -0,0 +1,213 @@ |
| +package calendar |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// The booker's own pages (booking spec §5.3–5.6, §5.8): confirm, |
| +// manage, the ICS, reschedule, cancel. Everything here is reached by a |
| +// token, and a wrong token is the router's own 404. |
| + |
| +// byToken resolves the URL's token or answers 404. |
| +func (h *handlers) byToken(w http.ResponseWriter, r *http.Request) (BookingView, string, bool) { |
| + token := chi.URLParam(r, "token") |
| + v, err := h.store.BookingByToken(r.Context(), token) |
| + if NotFound(err) { |
| + notFoundLikeTheRouter(w, r) |
| + return v, "", false |
| + } |
| + if err != nil { |
| + h.fail(w, r, "booking by token", err) |
| + return v, "", false |
| + } |
| + return v, token, true |
| +} |
| + |
| +// renderState shows whatever the booking is now: the confirm button |
| +// for a live hold, the manage page for a confirmed one, or the plain |
| +// "expired"/"cancelled" page. |
| +func (h *handlers) renderState(w http.ResponseWriter, r *http.Request, v BookingView, token string) { |
| + pb := publicOf(v, time.Now()) |
| + pb.Token = token |
| + switch { |
| + case pb.Held: |
| + render(w, "book_hold", h.page(r, "Confirm your booking", pb)) |
| + case pb.Live: |
| + render(w, "book_manage", h.page(r, pb.Title, pb)) |
| + default: |
| + render(w, "book_gone", h.page(r, pb.Title, map[string]any{ |
| + "Title": pb.Title, "Zone": pb.Zone, "Path": h.pagePathOf(v), |
| + "Why": goneWhy(pb), "Expired": pb.Expired, "Cancelled": pb.Cancelled, "CancelledBy": pb.CancelledBy, |
| + "ExpiresAt": pb.ExpiresAt, "HostName": pb.HostName, |
| + })) |
| + } |
| +} |
| + |
| +func goneWhy(pb publicBooking) string { |
| + switch { |
| + case pb.Expired: |
| + return "This hold expired at " + pb.ExpiresAt + ". The time went back on offer." |
| + case pb.Cancelled && pb.CancelledBy == CancelledByHost: |
| + return "This booking was cancelled by " + pb.HostName + "." |
| + case pb.Cancelled: |
| + return "You cancelled this booking." |
| + } |
| + return "This booking is no longer open." |
| +} |
| + |
| +// pagePathOf is the public page a booking came from, for the way back. |
| +func (h *handlers) pagePathOf(v BookingView) string { |
| + if v.Host.Handle == nil { |
| + return "/" |
| + } |
| + return "/book/" + *v.Host.Handle + "/" + v.Page.Code |
| +} |
| + |
| +// bookManage is GET /book/m/{token}. Nothing changes on a GET (B1). |
| +func (h *handlers) bookManage(w http.ResponseWriter, r *http.Request) { |
| + v, token, ok := h.byToken(w, r) |
| + if !ok { |
| + return |
| + } |
| + h.renderState(w, r, v, token) |
| +} |
| + |
| +// bookConfirm is POST /book/m/{token}/confirm. |
| +func (h *handlers) bookConfirm(w http.ResponseWriter, r *http.Request) { |
| + v, token, ok := h.byToken(w, r) |
| + if !ok { |
| + return |
| + } |
| + now := time.Now() |
| + changed, err := h.store.ConfirmBooking(r.Context(), v.ID, now) |
| + if err != nil { |
| + h.fail(w, r, "confirm", err) |
| + return |
| + } |
| + if changed { |
| + h.trySendNow(r.Context(), v.ID, now) |
| + } |
| + v, err = h.store.BookingByID(r.Context(), v.ID, false) |
| + if err != nil { |
| + h.fail(w, r, "re-read booking", err) |
| + return |
| + } |
| + h.renderState(w, r, v, token) |
| +} |
| + |
| +// bookICS is GET /book/m/{token}/calendar.ics — confirmed or cancelled |
| +// only; a hold has nothing to put in a calendar yet. |
| +func (h *handlers) bookICS(w http.ResponseWriter, r *http.Request) { |
| + v, _, ok := h.byToken(w, r) |
| + if !ok { |
| + return |
| + } |
| + if v.State != BookingConfirmed && v.State != BookingCancelled { |
| + notFoundLikeTheRouter(w, r) |
| + return |
| + } |
| + pb := publicOf(v, time.Now()) |
| + body := WriteBookingICS(pb, v.EventUID, v.Event.UpdatedAt, v.Event) |
| + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") |
| + w.Header().Set("Content-Disposition", `attachment; filename="booking.ics"`) |
| + w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| + _, _ = w.Write([]byte(body)) |
| +} |
| + |
| +// bookReschedulePicker is GET /book/m/{token}/reschedule: the page's |
| +// picker with the booking's own event out of busy and the EVENT's |
| +// duration, plus the revision the form must name. |
| +func (h *handlers) bookReschedulePicker(w http.ResponseWriter, r *http.Request) { |
| + v, token, ok := h.byToken(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !v.Live() { |
| + h.renderState(w, r, v, token) |
| + return |
| + } |
| + zone, chosen := pickZone(r, v.Host) |
| + if !chosen { |
| + zone = v.BookerZone |
| + } |
| + dur := int((v.Event.EndUTC - v.Event.StartUTC) / 60) |
| + d, ok := h.picker(w, r, v.Page, v.Host, zone, strings.TrimSpace(r.URL.Query().Get("day")), v.EventID, dur) |
| + if !ok { |
| + return |
| + } |
| + d.RescheduleToken, d.Rev, d.ZoneChosen = token, v.Event.Sequence, true |
| + d.Path = "/book/m/" + token + "/reschedule" |
| + render(w, "book_page", h.page(r, "Move "+v.Title, d)) |
| +} |
| + |
| +// bookReschedule is POST /book/m/{token}/reschedule with start, zone, rev. |
| +func (h *handlers) bookReschedule(w http.ResponseWriter, r *http.Request) { |
| + v, token, ok := h.byToken(w, r) |
| + if !ok { |
| + return |
| + } |
| + _ = r.ParseForm() |
| + start, okStart := parseUnix(r.FormValue("start")) |
| + rev, _ := strconv.Atoi(r.FormValue("rev")) |
| + if !okStart { |
| + http.Redirect(w, r, "/book/m/"+token+"/reschedule", http.StatusSeeOther) |
| + return |
| + } |
| + end := start + (v.Event.EndUTC - v.Event.StartUTC) |
| + now := time.Now() |
| + changed, err := h.store.RescheduleBooking(r.Context(), v.ID, start, end, rev, now) |
| + switch { |
| + case errors.Is(err, ErrSlotTaken): |
| + http.Redirect(w, r, "/book/m/"+token+"/reschedule?gone=1", http.StatusSeeOther) |
| + return |
| + case errors.Is(err, ErrOverCap): |
| + w.Header().Set("Retry-After", "86400") |
| + http.Error(w, "This booking has been moved as many times as it can be. Cancel it and book again.", http.StatusTooManyRequests) |
| + return |
| + case errors.Is(err, ErrIncompleteExpansion): |
| + w.Header().Set("Retry-After", "60") |
| + http.Error(w, "This page cannot work out free time just now. Try again shortly.", http.StatusServiceUnavailable) |
| + return |
| + case err != nil: |
| + h.fail(w, r, "reschedule", err) |
| + return |
| + } |
| + if changed { |
| + h.trySendNow(r.Context(), v.ID, now) |
| + } |
| + v, err = h.store.BookingByID(r.Context(), v.ID, false) |
| + if err != nil { |
| + h.fail(w, r, "re-read booking", err) |
| + return |
| + } |
| + h.renderState(w, r, v, token) |
| +} |
| + |
| +// bookCancel is POST /book/m/{token}/cancel. |
| +func (h *handlers) bookCancel(w http.ResponseWriter, r *http.Request) { |
| + v, token, ok := h.byToken(w, r) |
| + if !ok { |
| + return |
| + } |
| + now := time.Now() |
| + changed, err := h.store.CancelBooking(r.Context(), v.ID, CancelledByBooker, now) |
| + if err != nil { |
| + h.fail(w, r, "cancel", err) |
| + return |
| + } |
| + if changed { |
| + h.trySendNow(r.Context(), v.ID, now) |
| + } |
| + v, err = h.store.BookingByID(r.Context(), v.ID, false) |
| + if err != nil { |
| + h.fail(w, r, "re-read booking", err) |
| + return |
| + } |
| + h.renderState(w, r, v, token) |
| +} |
diff --git a/internal/calendar/handlers_booking_public.go b/internal/calendar/handlers_booking_public.go| new file mode 100644 |
| index 0000000..18cc496 |
| --- /dev/null |
| +++ b/internal/calendar/handlers_booking_public.go |
| @@ -0,0 +1,256 @@ |
| +package calendar |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// The public page (booking spec §5.1–5.2): a stranger picks a day, then |
| +// a slot, then types a name and an address, and the slot is HELD. Two |
| +// POSTs, no script anywhere in the path. |
| + |
| +// bookDay is one day the page offers, in the booker's zone. |
| +type bookDay struct { |
| + Date string // YYYY-MM-DD in the booker's zone |
| + Label string |
| + Count int |
| +} |
| + |
| +// bookSlot is one offered start, in the booker's zone. |
| +type bookSlot struct { |
| + StartUTC, EndUTC int64 |
| + Label string |
| +} |
| + |
| +// bookPageData is what book_page.html receives. Never a BookingView. |
| +type bookPageData struct { |
| + Handle, Code string |
| + Title, Description string |
| + HostName string |
| + DurationMin int |
| + Zone string |
| + Zones []string |
| + ZoneChosen bool |
| + Days []bookDay |
| + Day string |
| + DayLabel string |
| + Slots []bookSlot |
| + Picked *bookSlot |
| + Name, Address, Note string |
| + Error, ErrorField string |
| + Gone bool // "That time has just gone" |
| + Path string |
| + RescheduleToken string // set on the reschedule picker |
| + Rev int |
| +} |
| + |
| +// publicPage resolves the URL to a published page and its host, or the |
| +// router's own 404. |
| +func (h *handlers) publicPage(w http.ResponseWriter, r *http.Request) (BookingPageView, Person, bool) { |
| + page, host, err := h.store.PublicBookingPage(r.Context(), chi.URLParam(r, "handle"), chi.URLParam(r, "code")) |
| + if NotFound(err) { |
| + notFoundLikeTheRouter(w, r) |
| + return page, host, false |
| + } |
| + if err != nil { |
| + h.fail(w, r, "public booking page", err) |
| + return page, host, false |
| + } |
| + return page, host, true |
| +} |
| + |
| +// picker computes what the page shows: the days with anything free, or |
| +// one day's slots. from/to are the horizon; excludeEventID and |
| +// durationMin are for the reschedule picker. Errors are already |
| +// answered (503) when ok is false. |
| +func (h *handlers) picker(w http.ResponseWriter, r *http.Request, page BookingPageView, host Person, zone, day string, excludeEventID int64, durationMin int) (bookPageData, bool) { |
| + d := bookPageData{ |
| + Title: page.Title, Description: page.Description, HostName: hostNameOf(host), |
| + DurationMin: page.DurationMin, Zone: zone, Zones: zoneChoices(host), Day: day, Path: r.URL.Path, |
| + } |
| + release, ok := acquireSlots() |
| + if !ok { |
| + busyNow(w) |
| + return d, false |
| + } |
| + defer release() |
| + |
| + now := time.Now() |
| + bookerLoc, err := Zone(zone) |
| + if err != nil { |
| + h.fail(w, r, "booker zone", err) |
| + return d, false |
| + } |
| + hostLoc, _ := Zone(host.Zone) |
| + latest := now.In(hostLoc).AddDate(0, 0, page.HorizonDays) |
| + |
| + from, to := now, latest |
| + if day != "" { |
| + t, err := time.ParseInLocation(LayoutDate, day, bookerLoc) |
| + if err != nil || t.Before(now.In(bookerLoc).AddDate(0, 0, -1)) || t.After(latest) { |
| + notFoundLikeTheRouter(w, r) |
| + return d, false |
| + } |
| + // The booker's day, widened a day either side in the host's |
| + // zone: a slot on the booker's Sunday may be the host's Monday. |
| + from, to = t.AddDate(0, 0, -1), t.AddDate(0, 0, 2) |
| + d.DayLabel = t.Format("Monday 2 January") |
| + } |
| + slots, err := h.store.FreeSlots(r.Context(), page, host, now, from, to, excludeEventID, durationMin) |
| + if errors.Is(err, ErrIncompleteExpansion) { |
| + w.Header().Set("Retry-After", "60") |
| + http.Error(w, "This page cannot work out free time just now. Try again shortly.", http.StatusServiceUnavailable) |
| + return d, false |
| + } |
| + if err != nil { |
| + h.fail(w, r, "free slots", err) |
| + return d, false |
| + } |
| + |
| + counts := map[string]int{} |
| + var order []string |
| + for _, s := range slots { |
| + local := time.Unix(s.StartUTC, 0).In(bookerLoc) |
| + date := local.Format(LayoutDate) |
| + if day == "" { |
| + if counts[date] == 0 { |
| + order = append(order, date) |
| + } |
| + counts[date]++ |
| + continue |
| + } |
| + if date == day { |
| + d.Slots = append(d.Slots, bookSlot{StartUTC: s.StartUTC, EndUTC: s.EndUTC, Label: local.Format("15:04")}) |
| + } |
| + } |
| + for _, date := range order { |
| + t, _ := time.ParseInLocation(LayoutDate, date, bookerLoc) |
| + d.Days = append(d.Days, bookDay{Date: date, Label: t.Format("Monday 2 January"), Count: counts[date]}) |
| + } |
| + return d, true |
| +} |
| + |
| +func hostNameOf(host Person) string { |
| + if host.Handle != nil { |
| + return *host.Handle |
| + } |
| + return "" |
| +} |
| + |
| +// bookPage is GET /book/{handle}/{code}. |
| +func (h *handlers) bookPage(w http.ResponseWriter, r *http.Request) { |
| + page, host, ok := h.publicPage(w, r) |
| + if !ok { |
| + return |
| + } |
| + zone, chosen := pickZone(r, host) |
| + d, ok := h.picker(w, r, page, host, zone, strings.TrimSpace(r.URL.Query().Get("day")), 0, 0) |
| + if !ok { |
| + return |
| + } |
| + d.Handle, d.Code, d.ZoneChosen = chi.URLParam(r, "handle"), chi.URLParam(r, "code"), chosen |
| + render(w, "book_page", h.page(r, page.Title, d)) |
| +} |
| + |
| +// bookPost is POST /book/{handle}/{code}: the slot pick (no name yet) |
| +// or the hold. |
| +func (h *handlers) bookPost(w http.ResponseWriter, r *http.Request) { |
| + page, host, ok := h.publicPage(w, r) |
| + if !ok { |
| + return |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) |
| + if err := r.ParseForm(); err != nil { |
| + http.Error(w, "That form was too large.", http.StatusUnprocessableEntity) |
| + return |
| + } |
| + zone, chosen := pickZone(r, host) |
| + start, okStart := parseUnix(r.FormValue("start")) |
| + if !okStart { |
| + http.Redirect(w, r, r.URL.Path+"?zone="+zone, http.StatusSeeOther) |
| + return |
| + } |
| + bookerLoc, _ := Zone(zone) |
| + day := time.Unix(start, 0).In(bookerLoc).Format(LayoutDate) |
| + d, ok := h.picker(w, r, page, host, zone, day, 0, 0) |
| + if !ok { |
| + return |
| + } |
| + d.Handle, d.Code, d.ZoneChosen = chi.URLParam(r, "handle"), chi.URLParam(r, "code"), chosen |
| + for i := range d.Slots { |
| + if d.Slots[i].StartUTC == start { |
| + d.Picked = &d.Slots[i] |
| + } |
| + } |
| + if d.Picked == nil { |
| + d.Gone = true |
| + w.WriteHeader(http.StatusConflict) |
| + render(w, "book_page", h.page(r, page.Title, d)) |
| + return |
| + } |
| + if strings.TrimSpace(r.FormValue("name")) == "" && r.FormValue("address") == "" { |
| + // The slot pick: show the details form beneath it. |
| + render(w, "book_page", h.page(r, page.Title, d)) |
| + return |
| + } |
| + h.hold(w, r, page, host, d, zone) |
| +} |
| + |
| +// hold validates the details and makes the hold (§5.2). |
| +func (h *handlers) hold(w http.ResponseWriter, r *http.Request, page BookingPageView, host Person, d bookPageData, zone string) { |
| + d.Name = strings.TrimSpace(r.FormValue("name")) |
| + d.Address = normalizeAddress(r.FormValue("address")) |
| + d.Note = strings.TrimSpace(r.FormValue("note")) |
| + reject := func(field, msg string) { |
| + d.Error, d.ErrorField = msg, field |
| + w.WriteHeader(http.StatusUnprocessableEntity) |
| + render(w, "book_page", h.page(r, page.Title, d)) |
| + } |
| + switch { |
| + case d.Name == "" || utf8.RuneCountInString(d.Name) > MaxBookerNameRunes || len(d.Name) > MaxBookerNameBytes: |
| + reject("name", "Your name, please — up to sixty characters.") |
| + return |
| + case !reAddress.MatchString(d.Address) || reAddress.FindString(d.Address) != d.Address: |
| + reject("address", "That does not look like an email address.") |
| + return |
| + case utf8.RuneCountInString(d.Note) > MaxNoteRunes || len(d.Note) > MaxNoteBytes: |
| + reject("note", "Keep the note shorter — a thousand characters is plenty.") |
| + return |
| + } |
| + now := time.Now() |
| + b, err := h.store.HoldBooking(r.Context(), HoldInput{ |
| + Page: page, Host: host, StartUTC: d.Picked.StartUTC, EndUTC: d.Picked.EndUTC, |
| + Name: d.Name, Address: d.Address, Note: d.Note, BookerZone: zone, IPFP: h.ipFingerprint(r), |
| + }, now) |
| + switch { |
| + case errors.Is(err, ErrOverCap): |
| + w.Header().Set("Retry-After", "1800") |
| + w.WriteHeader(http.StatusTooManyRequests) |
| + render(w, "book_gone", h.page(r, page.Title, map[string]any{"Path": d.Path, "Zone": zone, |
| + "Title": page.Title, "Why": "Too many bookings in progress. Try again later."})) |
| + return |
| + case errors.Is(err, ErrSlotTaken): |
| + d.Gone, d.Picked = true, nil |
| + w.WriteHeader(http.StatusConflict) |
| + render(w, "book_page", h.page(r, page.Title, d)) |
| + return |
| + case errors.Is(err, ErrIncompleteExpansion): |
| + w.Header().Set("Retry-After", "60") |
| + http.Error(w, "This page cannot work out free time just now. Try again shortly.", http.StatusServiceUnavailable) |
| + return |
| + case err != nil: |
| + h.fail(w, r, "hold", err) |
| + return |
| + } |
| + // Whatever the mail does, the hold stands (B16). |
| + sent := h.trySendNow(r.Context(), b.ID, now) |
| + render(w, "book_sent", h.page(r, page.Title, map[string]any{ |
| + "Title": page.Title, "Address": d.Address, "Unsure": !sent, |
| + "ExpiresAt": instantWords(b.HoldExpiresAt, zone), "Path": d.Path, "Zone": zone, |
| + })) |
| +} |
diff --git a/internal/calendar/handlers_booking_util.go b/internal/calendar/handlers_booking_util.go| new file mode 100644 |
| index 0000000..720e64d |
| --- /dev/null |
| +++ b/internal/calendar/handlers_booking_util.go |
| @@ -0,0 +1,192 @@ |
| +package calendar |
| + |
| +import ( |
| + "net" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "amadan.net/oficina/calendar/internal/calendar/seal" |
| +) |
| + |
| +// What every public booking route shares (booking spec §2.7, §4.2, §5, |
| +// §7.2): the client's IP, the cheap gate in front of the expensive |
| +// work, the two headers, the zone the page offers, and the semaphore on |
| +// slot computation. |
| + |
| +// clientIP is the LAST element of X-Forwarded-For — the platform edge |
| +// REPLACES that header with the dialling client, and the app's socket |
| +// is reachable only from the edge — or the host of RemoteAddr when the |
| +// header is absent (tests, rastrillo dev). Canonicalised; unparseable |
| +// is the literal "unknown", one bucket for all of it. Never logged. |
| +func clientIP(r *http.Request) string { |
| + raw := "" |
| + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { |
| + parts := strings.Split(xff, ",") |
| + raw = strings.TrimSpace(parts[len(parts)-1]) |
| + } else if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { |
| + raw = host |
| + } else { |
| + raw = r.RemoteAddr |
| + } |
| + if ip := net.ParseIP(raw); ip != nil { |
| + return ip.String() |
| + } |
| + return "unknown" |
| +} |
| + |
| +// ipFingerprint is the keyed fingerprint the abuse caps are counted by. |
| +func (h *handlers) ipFingerprint(r *http.Request) string { |
| + fp, err := h.store.seal.Fingerprint(seal.PurposeBookingIP, clientIP(r)) |
| + if err != nil { |
| + return "unknown" |
| + } |
| + return fp |
| +} |
| + |
| +// publicHeaders: the token is in the path. |
| +func publicHeaders(w http.ResponseWriter) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Referrer-Policy", "no-referrer") |
| +} |
| + |
| +// notFoundLikeTheRouter is the SAME 404 an unknown route gets, so a |
| +// wrong token and a wrong path are indistinguishable (B10). chi's |
| +// default NotFound is http.NotFound. |
| +func notFoundLikeTheRouter(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| +} |
| + |
| +// zoneChoices is CommonZones plus the host's own zone when it is not |
| +// among them — the two sources a booker's zone may come from (§5.1). |
| +func zoneChoices(host Person) []string { |
| + out := append([]string(nil), CommonZones...) |
| + if host.Zone != "" && !IsCommonZone(host.Zone) { |
| + out = append(out, host.Zone) |
| + } |
| + return out |
| +} |
| + |
| +// pickZone reads ?zone= / the form's zone, bounded to zoneChoices; |
| +// anything else is the host's zone. chosen says whether the request |
| +// carried one, so the page can tell booking.js not to guess. |
| +func pickZone(r *http.Request, host Person) (zone string, chosen bool) { |
| + _ = r.ParseForm() |
| + want := strings.TrimSpace(r.FormValue("zone")) |
| + for _, z := range zoneChoices(host) { |
| + if z == want { |
| + return z, true |
| + } |
| + } |
| + if host.Zone == "" { |
| + return defaultZone, false |
| + } |
| + return host.Zone, false |
| +} |
| + |
| +// The gate: an in-process token bucket per IP fingerprint on every |
| +// public route (§7.2). 60 a minute, refilled one a second. Bounded to |
| +// gateMaxKeys; a key idle for gateIdle is evicted on the next insert; |
| +// full with no idle key, a NEW key is refused. Memory only. |
| +const ( |
| + gateBurst = 60 |
| + gateRefill = time.Second |
| + gateMaxKeys = 10000 |
| + gateIdle = 5 * time.Minute |
| +) |
| + |
| +type gateEntry struct { |
| + tokens float64 |
| + last time.Time |
| +} |
| + |
| +type gate struct { |
| + mu sync.Mutex |
| + keys map[string]*gateEntry |
| + now func() time.Time |
| +} |
| + |
| +func newGate() *gate { return &gate{keys: map[string]*gateEntry{}, now: time.Now} } |
| + |
| +// allow spends one token for key, or says why not: false with full=true |
| +// when the bucket had no room for a new key at all. |
| +func (g *gate) allow(key string) (ok bool) { |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + now := g.now() |
| + e := g.keys[key] |
| + if e == nil { |
| + if len(g.keys) >= gateMaxKeys { |
| + g.evictIdle(now) |
| + if len(g.keys) >= gateMaxKeys { |
| + return false |
| + } |
| + } |
| + e = &gateEntry{tokens: gateBurst, last: now} |
| + g.keys[key] = e |
| + } |
| + e.tokens += now.Sub(e.last).Seconds() / gateRefill.Seconds() |
| + if e.tokens > gateBurst { |
| + e.tokens = gateBurst |
| + } |
| + e.last = now |
| + if e.tokens < 1 { |
| + return false |
| + } |
| + e.tokens-- |
| + return true |
| +} |
| + |
| +func (g *gate) evictIdle(now time.Time) { |
| + for k, e := range g.keys { |
| + if now.Sub(e.last) > gateIdle { |
| + delete(g.keys, k) |
| + } |
| + } |
| +} |
| + |
| +// size is for the test that proves the bound. |
| +func (g *gate) size() int { |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + return len(g.keys) |
| +} |
| + |
| +// publicGate wraps every public route: the bucket, then the headers. |
| +func (h *handlers) publicGate(next http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + publicHeaders(w) |
| + if !h.gate.allow(h.ipFingerprint(r)) { |
| + w.Header().Set("Retry-After", "60") |
| + http.Error(w, "Too many requests. Try again in a minute.", http.StatusTooManyRequests) |
| + return |
| + } |
| + next(w, r) |
| + } |
| +} |
| + |
| +// slotSem bounds concurrent public slot computation (§4.2). Full → 503, |
| +// never a page computed from less than the whole busy list. |
| +var slotSem = make(chan struct{}, 4) |
| + |
| +func acquireSlots() (release func(), ok bool) { |
| + select { |
| + case slotSem <- struct{}{}: |
| + return func() { <-slotSem }, true |
| + default: |
| + return nil, false |
| + } |
| +} |
| + |
| +func busyNow(w http.ResponseWriter) { |
| + w.Header().Set("Retry-After", "5") |
| + http.Error(w, "Busy just now. Try again in a moment.", http.StatusServiceUnavailable) |
| +} |
| + |
| +// parseUnix reads a form's instant. |
| +func parseUnix(s string) (int64, bool) { |
| + n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) |
| + return n, err == nil && n > 0 |
| +} |
diff --git a/internal/calendar/mail_bounded.go b/internal/calendar/mail_bounded.go| index 1d60956..a59ac76 100644 |
| --- a/internal/calendar/mail_bounded.go |
| +++ b/internal/calendar/mail_bounded.go |
| @@ -1,6 +1,12 @@ |
| package calendar |
| -import "errors" |
| +import ( |
| + "context" |
| + "errors" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/mail" |
| +) |
| // ErrMailBusy is what a send returns when every in-flight slot of the |
| // bounded sender is taken. No SMTP call was made, so it is a DEFERRAL |
| @@ -12,3 +18,65 @@ var ErrMailBusy = errors.New("calendar: mail sender busy") |
| // not come back inside the caller's budget. The send may still be in |
| // progress and may still arrive; at-least-once holds. |
| var ErrMailTimeout = errors.New("calendar: mail send timed out") |
| + |
| +// Sender limits, as constants because they are policy. |
| +const ( |
| + maxSendsInFlight = 4 |
| + maxSendWait = 20 * time.Second |
| +) |
| + |
| +// boundedSender is the ONE wrapper every mail — reminders, sign-in, |
| +// booking — goes through (booking spec §6). |
| +// |
| +// It bounds two things and is honest about a third. At most |
| +// maxSendsInFlight sends run at once; a caller past that gets |
| +// ErrMailBusy at once. Each send waits at most min(maxSendWait, what is |
| +// left of the caller's context), then returns ErrMailTimeout. What it |
| +// cannot bound is the socket: rastrillo's mail.SMTP ignores its context |
| +// and sets no dial or protocol deadline, so an abandoned send holds a |
| +// goroutine and a connection until the kernel gives up. The semaphore |
| +// caps how many; the fix belongs upstream and is recorded as debt. |
| +type boundedSender struct { |
| + next mail.Sender |
| + slots chan struct{} |
| + wait time.Duration |
| +} |
| + |
| +func newBoundedSender(next mail.Sender) *boundedSender { |
| + return &boundedSender{next: next, slots: make(chan struct{}, maxSendsInFlight), wait: maxSendWait} |
| +} |
| + |
| +func (b *boundedSender) Send(ctx context.Context, to, subject, body string) error { |
| + select { |
| + case b.slots <- struct{}{}: |
| + default: |
| + return ErrMailBusy |
| + } |
| + |
| + wait := b.wait |
| + if dl, ok := ctx.Deadline(); ok { |
| + if left := time.Until(dl); left < wait { |
| + wait = left |
| + } |
| + } |
| + if wait <= 0 { |
| + <-b.slots |
| + return ErrMailTimeout |
| + } |
| + |
| + done := make(chan error, 1) |
| + go func() { |
| + // The slot is released when the underlying send RETURNS, |
| + // however long after this call gave up on it. That is the |
| + // bound on abandoned sends: at most maxSendsInFlight of them. |
| + defer func() { <-b.slots }() |
| + done <- b.next.Send(ctx, to, subject, body) |
| + }() |
| + |
| + select { |
| + case err := <-done: |
| + return err |
| + case <-time.After(wait): |
| + return ErrMailTimeout |
| + } |
| +} |
diff --git a/internal/calendar/render.go b/internal/calendar/render.go| index c3b8223..2eab125 100644 |
| --- a/internal/calendar/render.go |
| +++ b/internal/calendar/render.go |
| @@ -35,6 +35,7 @@ var pageNames = []string{ |
| "event", "event_form", "event_delete", "calendars", "share", |
| "import", "import_done", "settings", |
| "booking_pages", "booking_page_form", |
| + "book_page", "book_sent", "book_hold", "book_manage", "book_gone", |
| } |
| // pages is one *template.Template per page, each combining layout.html |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 0bee8ce..7e128d9 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -50,6 +50,19 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| // would make the feed work in a browser and nowhere else. |
| r.Get("/feed/{token}.ics", h.feed) |
| + // Booking, the stranger's side (booking spec §5). Inside CSRF and |
| + // outside the session, like the feed; every route passes the gate. |
| + // /book/m/... is registered BEFORE /book/{handle}/{code}, and "m" |
| + // is a reserved handle, so the two cannot collide. |
| + r.Get("/book/m/{token}", h.publicGate(h.bookManage)) |
| + r.Post("/book/m/{token}/confirm", h.publicGate(h.bookConfirm)) |
| + r.Get("/book/m/{token}/calendar.ics", h.publicGate(h.bookICS)) |
| + r.Get("/book/m/{token}/reschedule", h.publicGate(h.bookReschedulePicker)) |
| + r.Post("/book/m/{token}/reschedule", h.publicGate(h.bookReschedule)) |
| + r.Post("/book/m/{token}/cancel", h.publicGate(h.bookCancel)) |
| + r.Get("/book/{handle}/{code}", h.publicGate(h.bookPage)) |
| + r.Post("/book/{handle}/{code}", h.publicGate(h.bookPost)) |
| + |
| r.Group(func(r chi.Router) { |
| r.Use(authPlugin.RequireSession) |
| r.Use(a.requirePerson) |
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css| index e87b74e..ff4beb9 100644 |
| --- a/internal/calendar/static/calendar.css |
| +++ b/internal/calendar/static/calendar.css |
| @@ -507,3 +507,35 @@ |
| } |
| [cal-booked-when] { margin: 0; } |
| + |
| +/* ── Booking, the stranger's side ────────────────────────────────── */ |
| + |
| +[cal-book-zone-form] { |
| + align-items: center; |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + margin-block-end: var(--rst-sp-4); |
| +} |
| + |
| +[cal-book-days] { list-style: none; margin: 0; padding: 0; } |
| +[cal-book-days] li { padding-block: var(--rst-sp-1); } |
| +[cal-book-days] small { color: var(--rst-text-muted); margin-inline-start: var(--rst-sp-2); } |
| + |
| +/* Each slot is its own form: a button that posts an instant. */ |
| +[cal-book-slots] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + margin-block: var(--rst-sp-3); |
| +} |
| + |
| +[cal-book-details] { padding: var(--rst-sp-3); } |
| + |
| +[cal-book-manage-actions] { |
| + align-items: center; |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + margin-block: var(--rst-sp-4) var(--rst-sp-2); |
| +} |
diff --git a/internal/calendar/templates/book_gone.html b/internal/calendar/templates/book_gone.html| new file mode 100644 |
| index 0000000..5f820bd |
| --- /dev/null |
| +++ b/internal/calendar/templates/book_gone.html |
| @@ -0,0 +1,11 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Data.Title}}</h1> |
| + </div> |
| +</header> |
| + |
| +<p>{{.Data.Why}}</p> |
| +{{if .Data.Path}}<p><a rst-btn href="{{.Data.Path}}?zone={{.Data.Zone}}">Book a time</a></p>{{end}} |
| +{{end}} |
diff --git a/internal/calendar/templates/book_hold.html b/internal/calendar/templates/book_hold.html| new file mode 100644 |
| index 0000000..2b3457f |
| --- /dev/null |
| +++ b/internal/calendar/templates/book_hold.html |
| @@ -0,0 +1,22 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>Confirm your booking</h1> |
| + <p rst-page-header-sub>{{.Data.Title}} with {{.Data.HostName}}</p> |
| + </div> |
| +</header> |
| + |
| +{{$items := list |
| + (dict "Label" "When" "Value" .Data.When) |
| + (dict "Label" "Booked as" "Value" (printf "%s, %s" .Data.Name .Data.Address))}} |
| +{{template "detail-list" dict "Items" $items}} |
| +{{with .Data.HostWhen}}<p cal-elsewhere>That is {{.}} where {{$.Data.HostName}} is.</p>{{end}} |
| + |
| +{{/* ONE button, and it is a POST: a link that confirmed on GET would be |
| + confirmed by every link-scanning mail gateway (booking spec B1). */}} |
| +<form rst-form-actions method="post" action="/book/m/{{.Data.Token}}/confirm"> |
| + <button rst-btn="primary" type="submit">Confirm this booking</button> |
| +</form> |
| +<p cal-book-caveat>Didn't ask for this? Do nothing. The hold lapses at {{.Data.ExpiresAt}}.</p> |
| +{{end}} |
diff --git a/internal/calendar/templates/book_manage.html b/internal/calendar/templates/book_manage.html| new file mode 100644 |
| index 0000000..4b906ab |
| --- /dev/null |
| +++ b/internal/calendar/templates/book_manage.html |
| @@ -0,0 +1,27 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Data.Title}}</h1> |
| + <p rst-page-header-sub>with {{.Data.HostName}} · confirmed</p> |
| + </div> |
| +</header> |
| + |
| +{{with .Data.Description}}<p>{{.}}</p>{{end}} |
| + |
| +{{$items := list |
| + (dict "Label" "When" "Value" .Data.When) |
| + (dict "Label" "With" "Value" (printf "%s, %s" .Data.HostName .Data.HostAddress)) |
| + (dict "Label" "Booked as" "Value" (printf "%s, %s" .Data.Name .Data.Address))}} |
| +{{template "detail-list" dict "Items" $items}} |
| +{{with .Data.HostWhen}}<p cal-elsewhere>That is {{.}} where {{$.Data.HostName}} is.</p>{{end}} |
| + |
| +<div cal-book-manage-actions> |
| + <a rst-btn="primary" href="/book/m/{{.Data.Token}}/calendar.ics">Add to calendar</a> |
| + <a rst-btn href="/book/m/{{.Data.Token}}/reschedule">Reschedule</a> |
| + <form method="post" action="/book/m/{{.Data.Token}}/cancel"> |
| + <button rst-btn="danger" type="submit">Cancel this booking</button> |
| + </form> |
| +</div> |
| +<p cal-book-caveat>The calendar file is a download of this booking as it is now. If it moves, download it again.</p> |
| +{{end}} |
diff --git a/internal/calendar/templates/book_page.html b/internal/calendar/templates/book_page.html| new file mode 100644 |
| index 0000000..4f5c959 |
| --- /dev/null |
| +++ b/internal/calendar/templates/book_page.html |
| @@ -0,0 +1,77 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Data.Title}}</h1> |
| + <p rst-page-header-sub>{{if .Data.RescheduleToken}}Pick a new time.{{else}}{{duration .Data.DurationMin}} with {{.Data.HostName}}.{{end}}</p> |
| + </div> |
| +</header> |
| + |
| +{{with .Data.Description}}<p>{{.}}</p>{{end}} |
| + |
| +{{if .Data.Gone}} |
| +{{template "callout" dict "Tone" "warning" "Body" "That time has just gone. The times still free are below." "Alert" true}} |
| +{{end}} |
| +{{with .Data.Error}} |
| +{{template "callout" dict "Tone" "negative" "Body" . "Alert" true}} |
| +{{end}} |
| + |
| +{{/* The zone, as a plain form. Scriptless it is a select and a button; |
| + booking.js may preselect the browser's zone when none was chosen |
| + (data-chosen says one was). */}} |
| +<form method="get" action="{{.Data.Path}}" cal-book-zone-form> |
| + {{if .Data.Day}}<input type="hidden" name="day" value="{{.Data.Day}}">{{end}} |
| + <label rst-field-label for="zone">Times shown in</label> |
| + <select rst-input="short" id="zone" name="zone" cal-book-zone{{if .Data.ZoneChosen}} data-chosen="1"{{end}}> |
| + {{range .Data.Zones}}<option value="{{.}}"{{if eq . $.Data.Zone}} selected{{end}}>{{.}}</option>{{end}} |
| + </select> |
| + <button rst-btn="ghost compact" type="submit">Change</button> |
| +</form> |
| + |
| +{{if .Data.Picked}} |
| +{{/* Step two: the slot is fixed; now who is booking it. */}} |
| +<section rst-box> |
| + <header rst-box-head><h2>{{.Data.DayLabel}}, {{.Data.Picked.Label}}</h2></header> |
| + <form rst-form method="post" action="{{.Data.Path}}" cal-book-details> |
| + <input type="hidden" name="start" value="{{.Data.Picked.StartUTC}}"> |
| + <input type="hidden" name="zone" value="{{.Data.Zone}}"> |
| + {{template "field" dict "ID" "name" "Name" "name" "Label" "Your name" "Value" .Data.Name "Required" true "Autofocus" true |
| + "Error" (if_eq .Data.ErrorField "name" .Data.Error)}} |
| + {{template "field" dict "ID" "address" "Name" "address" "Label" "Your email" "Type" "email" "Value" .Data.Address "Required" true |
| + "Help" "A link to confirm goes here, and nothing else does." |
| + "Error" (if_eq .Data.ErrorField "address" .Data.Error)}} |
| + {{template "field-textarea" dict "Name" "note" "Label" "Anything to say beforehand?" "Value" .Data.Note "Rows" 3 |
| + "Error" (if_eq .Data.ErrorField "note" .Data.Error)}} |
| + {{template "form-foot" dict "Submit" "Hold this time" "CancelHref" (printf "%s?day=%s&zone=%s" .Data.Path .Data.Day .Data.Zone) "CancelLabel" "Pick another time"}} |
| + </form> |
| +</section> |
| +{{else if .Data.Day}} |
| +{{/* Step one: this day's slots, each a form of its own. */}} |
| +<p><a href="{{.Data.Path}}?zone={{.Data.Zone}}">← Other days</a></p> |
| +<h2>{{.Data.DayLabel}}</h2> |
| +{{if .Data.Slots}} |
| +<div cal-book-slots> |
| + {{range .Data.Slots}} |
| + <form method="post" action="{{$.Data.Path}}"> |
| + <input type="hidden" name="start" value="{{.StartUTC}}"> |
| + <input type="hidden" name="zone" value="{{$.Data.Zone}}"> |
| + {{with $.Data.Rev}}<input type="hidden" name="rev" value="{{.}}">{{end}} |
| + <button rst-btn type="submit" cal-book-slot>{{.Label}}</button> |
| + </form> |
| + {{end}} |
| +</div> |
| +{{else}} |
| +{{template "empty-state" dict "Body" "Nothing free that day any more."}} |
| +{{end}} |
| +{{else}} |
| +{{if .Data.Days}} |
| +<ul cal-book-days> |
| + {{range .Data.Days}} |
| + <li><a href="{{$.Data.Path}}?day={{.Date}}&zone={{$.Data.Zone}}">{{.Label}}</a> <small>{{.Count}} free</small></li> |
| + {{end}} |
| +</ul> |
| +{{else}} |
| +{{template "empty-state" dict "Body" "Nothing free in the next while. Try again in a few days."}} |
| +{{end}} |
| +{{end}} |
| +{{end}} |
diff --git a/internal/calendar/templates/book_sent.html b/internal/calendar/templates/book_sent.html| new file mode 100644 |
| index 0000000..a4077c2 |
| --- /dev/null |
| +++ b/internal/calendar/templates/book_sent.html |
| @@ -0,0 +1,16 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>Check your mail</h1> |
| + <p rst-page-header-sub>{{.Data.Title}}</p> |
| + </div> |
| +</header> |
| + |
| +<p>We sent one link to <strong>{{.Data.Address}}</strong>. Open it and press the button there, and the time is yours.</p> |
| +<p>The hold lapses at {{.Data.ExpiresAt}} if nobody presses it.</p> |
| +{{if .Data.Unsure}} |
| +{{template "callout" dict "Tone" "warning" "Body" "If nothing arrives in a few minutes, the hold lapses at that time and you can try again."}} |
| +{{end}} |
| +<p><a href="{{.Data.Path}}?zone={{.Data.Zone}}">← Back</a></p> |
| +{{end}} |
diff --git a/internal/calendar/templates/mail/cancelled_booker.txt b/internal/calendar/templates/mail/cancelled_booker.txt| new file mode 100644 |
| index 0000000..505187c |
| --- /dev/null |
| +++ b/internal/calendar/templates/mail/cancelled_booker.txt |
| @@ -0,0 +1,3 @@ |
| +{{.Title}} with {{.HostName}}, {{.When}}, is cancelled{{if eq .CancelledBy "host"}} by {{.HostName}}{{else}} — you cancelled it{{end}}. |
| + |
| +If you added it to a calendar, remove it there; the file does not update itself. |
diff --git a/internal/calendar/templates/mail/cancelled_host.txt b/internal/calendar/templates/mail/cancelled_host.txt| new file mode 100644 |
| index 0000000..66294af |
| --- /dev/null |
| +++ b/internal/calendar/templates/mail/cancelled_host.txt |
| @@ -0,0 +1,5 @@ |
| +{{.Name}} cancelled {{.Title}}, {{if .HostWhen}}{{.HostWhen}}{{else}}{{.When}}{{end}}. |
| + |
| +The event stays on your calendar, marked cancelled: |
| + |
| +{{.EventURL}} |
diff --git a/internal/calendar/templates/mail/confirmed.txt b/internal/calendar/templates/mail/confirmed.txt| new file mode 100644 |
| index 0000000..6e9f8a4 |
| --- /dev/null |
| +++ b/internal/calendar/templates/mail/confirmed.txt |
| @@ -0,0 +1,7 @@ |
| +{{if .Stale}}This booking has since been {{.State}}. What follows is the confirmation as it was. |
| + |
| +{{end}}{{.Name}} ({{.Address}}) booked {{.Title}}, {{if .HostWhen}}{{.HostWhen}}{{else}}{{.When}}{{end}}. |
| + |
| +It is on your calendar: |
| + |
| +{{.EventURL}} |
diff --git a/internal/calendar/templates/mail/hold.txt b/internal/calendar/templates/mail/hold.txt| new file mode 100644 |
| index 0000000..3576a35 |
| --- /dev/null |
| +++ b/internal/calendar/templates/mail/hold.txt |
| @@ -0,0 +1,7 @@ |
| +Somebody — we hope you — asked to book {{.Title}} with {{.HostName}} on {{.When}}. |
| + |
| +If that was you, confirm it here: |
| + |
| +{{.ManageURL}} |
| + |
| +If not, do nothing. The hold lapses at {{.ExpiresAt}} and the time goes back on offer. |
diff --git a/internal/calendar/templates/mail/rescheduled_booker.txt b/internal/calendar/templates/mail/rescheduled_booker.txt| new file mode 100644 |
| index 0000000..a782ce4 |
| --- /dev/null |
| +++ b/internal/calendar/templates/mail/rescheduled_booker.txt |
| @@ -0,0 +1,7 @@ |
| +{{.Title}} with {{.HostName}} has moved. It is now {{.When}}. |
| + |
| +Reschedule or cancel here: |
| + |
| +{{if .ManageURL}}{{.ManageURL}}{{else}}the link in your first mail{{end}} |
| + |
| +If you added this to a calendar, download it again — the file does not update itself. |
diff --git a/internal/calendar/templates/mail/rescheduled_host.txt b/internal/calendar/templates/mail/rescheduled_host.txt| new file mode 100644 |
| index 0000000..5772374 |
| --- /dev/null |
| +++ b/internal/calendar/templates/mail/rescheduled_host.txt |
| @@ -0,0 +1,3 @@ |
| +{{.Name}} moved {{.Title}}. It is now {{if .HostWhen}}{{.HostWhen}}{{else}}{{.When}}{{end}}. |
| + |
| +{{.EventURL}} |
diff --git a/internal/calendartest/booking_abuse_test.go b/internal/calendartest/booking_abuse_test.go| new file mode 100644 |
| index 0000000..3a58026 |
| --- /dev/null |
| +++ b/internal/calendartest/booking_abuse_test.go |
| @@ -0,0 +1,106 @@ |
| +package calendartest |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// The caps, the gate and the day bound (booking spec §4.2, §7). |
| + |
| +func fromIP(t *testing.T, h http.Handler, ip, target string, form url.Values) *httptest.ResponseRecorder { |
| + t.Helper() |
| + method := http.MethodGet |
| + if form != nil { |
| + method = http.MethodPost |
| + } |
| + req := calendar.RequestForTest(nil, method, target, form) |
| + req.Header.Set("X-Forwarded-For", ip) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +func TestTheSixthOpenHoldFromOneIPIs429(t *testing.T) { |
| + app, _, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + _, starts := firstDaySlots(t, h, path) |
| + for i := 0; i < 5; i++ { |
| + rec := fromIP(t, h, "203.0.113.9", path, holdForm(starts[i], "S", "s"+string(rune('a'+i))+"@example.com")) |
| + if rec.Code != 200 { |
| + t.Fatalf("hold %d: %d", i, rec.Code) |
| + } |
| + } |
| + rec := fromIP(t, h, "203.0.113.9", path, holdForm(starts[5], "S", "sx@example.com")) |
| + if rec.Code != http.StatusTooManyRequests { |
| + t.Errorf("sixth hold from one IP: %d, want 429", rec.Code) |
| + } |
| + // A different IP is fine. |
| + if rec := fromIP(t, h, "203.0.113.10", path, holdForm(starts[5], "S", "sy@example.com")); rec.Code != 200 { |
| + t.Errorf("hold from another IP: %d", rec.Code) |
| + } |
| +} |
| + |
| +func TestTheThirdOpenHoldForOneAddressIs429(t *testing.T) { |
| + app, _, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + _, starts := firstDaySlots(t, h, path) |
| + for i := 0; i < 2; i++ { |
| + fromIP(t, h, "203.0.113."+string(rune('1'+i)), path, holdForm(starts[i], "S", "same@example.com")) |
| + } |
| + if rec := fromIP(t, h, "203.0.113.5", path, holdForm(starts[2], "S", "same@example.com")); rec.Code != http.StatusTooManyRequests { |
| + t.Errorf("third hold for one address: %d, want 429", rec.Code) |
| + } |
| +} |
| + |
| +func TestTheGateRefusesTheSixtyFirstRequestInAMinute(t *testing.T) { |
| + app, _, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + var last int |
| + for i := 0; i < 61; i++ { |
| + last = fromIP(t, h, "198.51.100.7", path, nil).Code |
| + } |
| + if last != http.StatusTooManyRequests { |
| + t.Errorf("61st request: %d, want 429", last) |
| + } |
| + if rec := fromIP(t, h, "198.51.100.8", path, nil); rec.Code != 200 { |
| + t.Errorf("another IP after the gate closed on one: %d", rec.Code) |
| + } |
| +} |
| + |
| +func TestADayOutsideTheHorizonIs404AndAStrangeZoneFallsBack(t *testing.T) { |
| + app, _, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + far := time.Now().AddDate(0, 0, 60).Format("2006-01-02") |
| + if rec := strangerGet(t, h, path+"?day="+far); rec.Code != http.StatusNotFound { |
| + t.Errorf("a day past the horizon: %d, want 404", rec.Code) |
| + } |
| + past := time.Now().AddDate(0, 0, -10).Format("2006-01-02") |
| + if rec := strangerGet(t, h, path+"?day="+past); rec.Code != http.StatusNotFound { |
| + t.Errorf("a day in the past: %d, want 404", rec.Code) |
| + } |
| + body := strangerGet(t, h, path+"?zone=Nowhere/Town").Body.String() |
| + if !strings.Contains(body, `<option value="UTC" selected`) { |
| + t.Error("an unknown zone did not fall back to the host's") |
| + } |
| +} |
| + |
| +func TestAnOversizedFormIs422(t *testing.T) { |
| + app, _, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + _, starts := firstDaySlots(t, h, path) |
| + form := holdForm(starts[0], "A", "a@example.com") |
| + form.Set("note", strings.Repeat("x", 20<<10)) |
| + if rec := strangerPost(t, h, path, form); rec.Code != http.StatusUnprocessableEntity { |
| + t.Errorf("20 KiB form: %d, want 422", rec.Code) |
| + } |
| + form.Set("note", strings.Repeat("é", 1500)) // 1500 runes, 3000 bytes: over the rune limit |
| + if rec := strangerPost(t, h, path, form); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "shorter") { |
| + t.Errorf("a 1500-rune note: %d", rec.Code) |
| + } |
| +} |
diff --git a/internal/calendartest/booking_http_test.go b/internal/calendartest/booking_http_test.go| new file mode 100644 |
| index 0000000..d5114ab |
| --- /dev/null |
| +++ b/internal/calendartest/booking_http_test.go |
| @@ -0,0 +1,274 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +// A stranger's whole journey, through the router, with no session and |
| +// no script (booking spec §5). |
| + |
| +// publishedPage is a host with a handle and a published every-day 9–5 |
| +// page, seen through a picky mailer so the tests can read what went out. |
| +func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string, calendar.Person) { |
| + t.Helper() |
| + mailer := &pickyMailer{} |
| + app := newAppWithMailer(t, mailer) |
| + session := app.SignInForTest(t, "host@example.com") |
| + h := app.Handler() |
| + postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "week_start": {"1"}, |
| + "default_reminder_min": {"10"}, "default_all_day_min": {"0"}, "handle": {"paul"}}) |
| + rec := postAs(t, h, session, "/settings/booking", url.Values{"title": {"Quick chat"}, "description": {"Half an hour."}, |
| + "availability": {"every day 9-5"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}}) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("create page: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}) |
| + body := getAs(t, h, session, "/settings/booking").Body.String() |
| + m := regexp.MustCompile(`/book/paul/[a-z0-9]{8}`).FindString(body) |
| + if m == "" { |
| + t.Fatalf("no public URL on the list:\n%s", body) |
| + } |
| + mailer.reset() |
| + host := app.PersonByAddressForTest(t, "host@example.com") |
| + return app, mailer, m, host |
| +} |
| + |
| +func strangerGet(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodGet, target, nil)) |
| + return rec |
| +} |
| + |
| +func strangerPost(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodPost, target, form)) |
| + return rec |
| +} |
| + |
| +var ( |
| + reStart = regexp.MustCompile(`name="start" value="(\d+)"`) |
| + reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`) |
| + reDay = regexp.MustCompile(`\?day=(\d{4}-\d{2}-\d{2})`) |
| +) |
| + |
| +// firstDaySlots opens the page, follows the first day link, and returns |
| +// that day's path and its slot starts. |
| +func firstDaySlots(t *testing.T, h http.Handler, path string) (string, []string) { |
| + t.Helper() |
| + page := strangerGet(t, h, path) |
| + if page.Code != 200 { |
| + t.Fatalf("page: %d\n%s", page.Code, page.Body.String()) |
| + } |
| + m := reDay.FindStringSubmatch(page.Body.String()) |
| + if m == nil { |
| + t.Fatalf("no day links:\n%s", page.Body.String()) |
| + } |
| + dayPath := path + "?day=" + m[1] + "&zone=UTC" |
| + day := strangerGet(t, h, dayPath) |
| + var starts []string |
| + for _, s := range reStart.FindAllStringSubmatch(day.Body.String(), -1) { |
| + starts = append(starts, s[1]) |
| + } |
| + if len(starts) == 0 { |
| + t.Fatalf("no slots on the day page:\n%s", day.Body.String()) |
| + } |
| + return dayPath, starts |
| +} |
| + |
| +func holdForm(start, name, address string) url.Values { |
| + return url.Values{"start": {start}, "zone": {"UTC"}, "name": {name}, "address": {address}, "note": {"About the thing"}} |
| +} |
| + |
| +func TestAStrangerHoldsAndOnlyTheButtonConfirms(t *testing.T) { |
| + app, mailer, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + _, starts := firstDaySlots(t, h, path) |
| + |
| + // Step one: the slot pick, no name yet. |
| + pick := strangerPost(t, h, path, url.Values{"start": {starts[0]}, "zone": {"UTC"}}) |
| + if pick.Code != 200 || !strings.Contains(pick.Body.String(), `name="name"`) { |
| + t.Fatalf("slot pick: %d\n%s", pick.Code, pick.Body.String()) |
| + } |
| + // Step two: the hold. |
| + rec := strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "doc@tito.io") { |
| + t.Fatalf("hold: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + if n := mailer.count(); n != 1 || mailer.to(0) != "doc@tito.io" { |
| + t.Fatalf("mails after hold = %d to %q, want one to the booker", n, mailer.to(0)) |
| + } |
| + link := reManage.FindString(mailer.body(0)) |
| + if link == "" { |
| + t.Fatalf("no manage link in:\n%s", mailer.body(0)) |
| + } |
| + // The link's GET changes nothing. |
| + page := strangerGet(t, h, link) |
| + if page.Code != 200 || !strings.Contains(page.Body.String(), "Confirm this booking") { |
| + t.Fatalf("link GET: %d\n%s", page.Code, page.Body.String()) |
| + } |
| + if mailer.count() != 1 { |
| + t.Error("a GET of the link mailed somebody") |
| + } |
| + // The button confirms, once. |
| + man := strangerPost(t, h, link+"/confirm", nil) |
| + if man.Code != 200 || !strings.Contains(man.Body.String(), "Reschedule") { |
| + t.Fatalf("confirm: %d\n%s", man.Code, man.Body.String()) |
| + } |
| + if mailer.count() != 2 || mailer.to(1) != "host@example.com" || !strings.Contains(mailer.body(1), "Doc") { |
| + t.Errorf("host mail after confirm: count %d to %q", mailer.count(), mailer.to(1)) |
| + } |
| + strangerPost(t, h, link+"/confirm", nil) |
| + if mailer.count() != 2 { |
| + t.Error("confirming twice mailed the host twice") |
| + } |
| + if page.Header().Get("Cache-Control") != "no-store" || page.Header().Get("Referrer-Policy") != "no-referrer" { |
| + t.Error("public responses are missing no-store / no-referrer") |
| + } |
| +} |
| + |
| +func TestTheICSIsARequestWithNoManagementLinkAndNoHostPrivateFields(t *testing.T) { |
| + app, mailer, path, host := publishedPage(t) |
| + h := app.Handler() |
| + st := app.StoreForTest() |
| + ctx := context.Background() |
| + _, starts := firstDaySlots(t, h, path) |
| + strangerPost(t, h, path, holdForm(starts[0], "Doe, Jane \"JJ\": Support", "doc@tito.io")) |
| + link := reManage.FindString(mailer.body(0)) |
| + strangerPost(t, h, link+"/confirm", nil) |
| + |
| + // The host edits everything private. |
| + v, _ := st.BookingByToken(ctx, strings.TrimPrefix(link, "/book/m/")) |
| + ev, _ := st.Event(ctx, host.ID, v.EventID) |
| + in := calendar.EventInput{CalendarID: ev.CalendarID, Title: ev.Title, Location: "Room 9", Notes: "private", |
| + AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, Kind: ev.Kind, Status: ev.Status} |
| + if err := st.UpdateEvent(ctx, host.ID, v.EventID, in); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.SetAttendees(ctx, v.EventID, []string{"doc@tito.io", "boss@example.com"}) |
| + |
| + ics := strangerGet(t, h, link+"/calendar.ics") |
| + body := ics.Body.String() |
| + for _, want := range []string{"METHOD:REQUEST", "SUMMARY:Quick chat", "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io", |
| + "ORGANIZER:mailto:host@example.com", "SEQUENCE:2", "STATUS:CONFIRMED", "UID:"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("ics lacks %q:\n%s", want, body) |
| + } |
| + } |
| + for _, never := range []string{"URL:", "CN=", "Room 9", "private", "boss@example.com", "LOCATION"} { |
| + if strings.Contains(body, never) { |
| + t.Errorf("ics carries %q:\n%s", never, body) |
| + } |
| + } |
| + if ct := ics.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") { |
| + t.Errorf("content-type %q", ct) |
| + } |
| + if events, _, err := calendar.ParseICS(strings.NewReader(body), "UTC"); err != nil || len(events) != 1 { |
| + t.Errorf("our own parser: %d events, %v", len(events), err) |
| + } |
| + man := strangerGet(t, h, link).Body.String() |
| + for _, never := range []string{"Room 9", "private", "boss@example.com"} { |
| + if strings.Contains(man, never) { |
| + t.Errorf("the manage page shows the host's private %q", never) |
| + } |
| + } |
| + // A held booking has no ICS. |
| + strangerPost(t, h, path, holdForm(starts[1], "B", "b@example.com")) |
| + held := reManage.FindString(mailer.body(mailer.count() - 1)) |
| + if rec := strangerGet(t, h, held+"/calendar.ics"); rec.Code != http.StatusNotFound { |
| + t.Errorf("ics for a hold: %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +func TestTheSameSlotCannotBeHeldTwiceAndAWrongTokenIsTheRouters404(t *testing.T) { |
| + app, _, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + _, starts := firstDaySlots(t, h, path) |
| + strangerPost(t, h, path, holdForm(starts[0], "A", "a@example.com")) |
| + rec := strangerPost(t, h, path, holdForm(starts[0], "B", "b@example.com")) |
| + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "just gone") { |
| + t.Errorf("second hold: %d", rec.Code) |
| + } |
| + a := strangerGet(t, h, "/book/m/"+strings.Repeat("0", 64)) |
| + b := strangerGet(t, h, "/no/such/route") |
| + if a.Code != 404 || a.Body.String() != b.Body.String() { |
| + t.Errorf("token 404 (%d) differs from route 404 (%d)", a.Code, b.Code) |
| + } |
| + if rec := strangerGet(t, h, "/book/nobody/zzzzzzzz"); rec.Code != 404 || rec.Body.String() != b.Body.String() { |
| + t.Error("an unknown handle is not the router's 404") |
| + } |
| +} |
| + |
| +func TestRescheduleNeedsTheRevisionAndCancelTellsBoth(t *testing.T) { |
| + app, mailer, path, _ := publishedPage(t) |
| + h := app.Handler() |
| + _, starts := firstDaySlots(t, h, path) |
| + strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| + link := reManage.FindString(mailer.body(0)) |
| + strangerPost(t, h, link+"/confirm", nil) |
| + mailer.reset() |
| + |
| + picker := strangerGet(t, h, link+"/reschedule?day="+time.Now().UTC().AddDate(0, 0, 1).Format("2006-01-02")+"&zone=UTC") |
| + if picker.Code != 200 { |
| + t.Fatalf("picker: %d\n%s", picker.Code, picker.Body.String()) |
| + } |
| + rev := regexp.MustCompile(`name="rev" value="(\d+)"`).FindStringSubmatch(picker.Body.String()) |
| + next := reStart.FindAllStringSubmatch(picker.Body.String(), -1) |
| + if rev == nil || len(next) < 2 { |
| + t.Fatalf("picker lacks rev or slots:\n%s", picker.Body.String()) |
| + } |
| + form := url.Values{"start": {next[1][1]}, "zone": {"UTC"}, "rev": {rev[1]}} |
| + rec := strangerPost(t, h, link+"/reschedule", form) |
| + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Reschedule") { |
| + t.Fatalf("reschedule: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + if mailer.count() != 2 { |
| + t.Errorf("reschedule mailed %d, want booker and host", mailer.count()) |
| + } |
| + if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "SEQUENCE:2") { |
| + t.Error("SEQUENCE not bumped by the move") |
| + } |
| + // The same form again: stale rev, nothing happens. |
| + mailer.reset() |
| + strangerPost(t, h, link+"/reschedule", form) |
| + if mailer.count() != 0 { |
| + t.Error("a resubmitted reschedule form sent mail") |
| + } |
| + rec = strangerPost(t, h, link+"/cancel", nil) |
| + if rec.Code != 200 || mailer.count() != 2 { |
| + t.Errorf("cancel: %d, mails %d", rec.Code, mailer.count()) |
| + } |
| + if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "METHOD:CANCEL") { |
| + t.Error("no METHOD:CANCEL after cancel") |
| + } |
| + mailer.reset() |
| + strangerPost(t, h, link+"/cancel", nil) |
| + if mailer.count() != 0 { |
| + t.Error("cancelling twice mailed again") |
| + } |
| +} |
| + |
| +func TestAHoldWhoseMailIsRefusedStillStands(t *testing.T) { |
| + app, mailer, path, _ := publishedPage(t) |
| + mailer.refuse = "bounce@example.com" |
| + h := app.Handler() |
| + _, starts := firstDaySlots(t, h, path) |
| + rec := strangerPost(t, h, path, holdForm(starts[0], "B", "bounce@example.com")) |
| + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "If nothing arrives") { |
| + t.Errorf("refused mail: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + // The slot is still held. |
| + if again := strangerPost(t, h, path, holdForm(starts[0], "C", "c@example.com")); again.Code != http.StatusConflict { |
| + t.Errorf("the slot was released: %d", again.Code) |
| + } |
| +} |
diff --git a/internal/calendartest/delivery_test.go b/internal/calendartest/delivery_test.go| index 30507ac..6053699 100644 |
| --- a/internal/calendartest/delivery_test.go |
| +++ b/internal/calendartest/delivery_test.go |
| @@ -27,24 +27,56 @@ import ( |
| type pickyMailer struct { |
| refuse string |
| - mu sync.Mutex |
| - delivered []string |
| - attempts int |
| + mu sync.Mutex |
| + delivered []string |
| + recipients []string |
| + attempts int |
| } |
| -func (m *pickyMailer) Send(_ context.Context, _, subject, body string) error { |
| +func (m *pickyMailer) Send(_ context.Context, to, subject, body string) error { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| m.attempts++ |
| - if m.refuse != "" && strings.Contains(subject+body, m.refuse) { |
| + if m.refuse != "" && strings.Contains(to+subject+body, m.refuse) { |
| // Shaped like the real thing: rastrillo's mailer formats the |
| // recipient into its error, which is what scrub exists for. |
| return fmt.Errorf("send to nobody@example.com: connection refused") |
| } |
| m.delivered = append(m.delivered, subject+"\n"+body) |
| + m.recipients = append(m.recipients, to) |
| return nil |
| } |
| +func (m *pickyMailer) count() int { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return len(m.delivered) |
| +} |
| + |
| +func (m *pickyMailer) to(i int) string { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if i < 0 || i >= len(m.recipients) { |
| + return "" |
| + } |
| + return m.recipients[i] |
| +} |
| + |
| +func (m *pickyMailer) body(i int) string { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if i < 0 || i >= len(m.delivered) { |
| + return "" |
| + } |
| + return m.delivered[i] |
| +} |
| + |
| +func (m *pickyMailer) reset() { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.delivered, m.recipients = nil, nil |
| +} |
| + |
| func (m *pickyMailer) deliveredContaining(needle string) bool { |
| m.mu.Lock() |
| defer m.mu.Unlock() |