oficina / calendar Public

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>
Paul Campbell pushed by paul@keymail.dev 23e951d44b7931ef32c5aeaa0c1ab279bc176373 parent 0f12b84
27 files changed, +1665 −9
  • .impeccable/hook.cache.json +1 −1
  • internal/calendar/app.go +5 −1
  • internal/calendar/booking_ics.go +51 −0
  • internal/calendar/booking_mail.go +120 −0
  • internal/calendar/booking_public.go +104 −0
  • internal/calendar/handlers.go +6 −1
  • internal/calendar/handlers_booking_manage.go +213 −0
  • internal/calendar/handlers_booking_public.go +256 −0
  • internal/calendar/handlers_booking_util.go +192 −0
  • internal/calendar/mail_bounded.go +69 −1
  • internal/calendar/render.go +1 −0
  • internal/calendar/routes.go +13 −0
  • internal/calendar/static/calendar.css +32 −0
  • internal/calendar/templates/book_gone.html +11 −0
  • internal/calendar/templates/book_hold.html +22 −0
  • internal/calendar/templates/book_manage.html +27 −0
  • internal/calendar/templates/book_page.html +77 −0
  • internal/calendar/templates/book_sent.html +16 −0
  • internal/calendar/templates/mail/cancelled_booker.txt +3 −0
  • internal/calendar/templates/mail/cancelled_host.txt +5 −0
  • internal/calendar/templates/mail/confirmed.txt +7 −0
  • internal/calendar/templates/mail/hold.txt +7 −0
  • internal/calendar/templates/mail/rescheduled_booker.txt +7 −0
  • internal/calendar/templates/mail/rescheduled_host.txt +3 −0
  • internal/calendartest/booking_abuse_test.go +106 −0
  • internal/calendartest/booking_http_test.go +274 −0
  • internal/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}} &middot; 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}}">&larr; 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}}&amp;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}}">&larr; 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()