diff --git a/go.mod b/go.mod index 682494a..5988d42 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module git.dev.alexdunmow.com/block/calcomblock -go 1.26.4 +go 1.26.6 require ( - git.dev.alexdunmow.com/block/pluginsdk v0.3.5 + git.dev.alexdunmow.com/block/pluginsdk v0.3.7 github.com/a-h/templ v0.3.1020 github.com/go-chi/chi/v5 v5.3.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 01180ed..22bb311 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -git.dev.alexdunmow.com/block/pluginsdk v0.3.5 h1:h8YZTgnB/+WQ1yPS5MbJr2aPKuv/kudr93nQowwH8nM= -git.dev.alexdunmow.com/block/pluginsdk v0.3.5/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM= +git.dev.alexdunmow.com/block/pluginsdk v0.3.7 h1:PUT1YElhXre43m1UejDabSqRPAnx1BV7BEWKpG++7Rs= +git.dev.alexdunmow.com/block/pluginsdk v0.3.7/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= diff --git a/handler.go b/handler.go index 1165a70..2f2bfd1 100644 --- a/handler.go +++ b/handler.go @@ -15,10 +15,10 @@ import ( "sync" "time" + "git.dev.alexdunmow.com/block/calcomblock/internal/helpers" "git.dev.alexdunmow.com/block/pluginsdk/auth" "git.dev.alexdunmow.com/block/pluginsdk/plugin" "git.dev.alexdunmow.com/block/pluginsdk/rbac" - "git.dev.alexdunmow.com/block/calcomblock/internal/helpers" "github.com/go-chi/chi/v5" "github.com/nyaruka/phonenumbers" ) @@ -500,7 +500,11 @@ func (h *CalcomHandler) HandleGetSlots(w http.ResponseWriter, r *http.Request) { continue } timed = append(timed, timedSlot{at: local, slot: TimeSlot{ - Start: ts, + // UTC (…Z), never the event-timezone offset Cal.com may return: + // this value is carried raw in the slot button's query string, and + // a `+HH:MM` offset decodes to a space server-side, corrupting the + // timestamp Cal.com then rejects on booking. + Start: t.UTC().Format(time.RFC3339), DisplayTime: local.Format("3:04 PM"), DateKey: dateKey, }}) diff --git a/plugin.mod b/plugin.mod index 2e96ce2..4b86164 100644 --- a/plugin.mod +++ b/plugin.mod @@ -2,7 +2,7 @@ name = "calcomblock" display_name = "Cal.com Booking" scope = "@ninja" -version = "2.0.12" +version = "2.0.13" description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver." kind = "plugin" categories = ["forms"] @@ -11,3 +11,5 @@ allowed_hosts = ["api.cal.com"] [compatibility] block_core = ">=0.3.4" +admin_api = ">=0.1.2" +plugin_sdk = ">=0.3.7" diff --git a/slot_start_encoding_test.go b/slot_start_encoding_test.go new file mode 100644 index 0000000..649001a --- /dev/null +++ b/slot_start_encoding_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "html" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "testing" + "time" +) + +// formHxGetRe extracts the hx-get URL of a slot button pointing at /form. +var formHxGetRe = regexp.MustCompile(`hx-get="([^"]*/form[^"]*)"`) + +// TestHandleGetSlots_OffsetSlotStartSurvivesURLParsing pins the bidbuddy.com.au +// (Australia/Perth) production failure: Cal.com returns slot starts in the +// event's own timezone with a numeric `+08:00` offset. That value is carried in +// the slot button's hx-get query string; a raw `+` in a query string decodes to +// a SPACE server-side, corrupting the timestamp ("2026-06-11T10:00:00 08:00"). +// The corrupted value flows form → book → Cal.com, which rejects it with +// "start must be a valid ISO 8601 date string". The slot's machine value must +// therefore round-trip through URL query parsing as a valid RFC3339 timestamp. +func TestHandleGetSlots_OffsetSlotStartSurvivesURLParsing(t *testing.T) { + // 2026-06-11T10:00:00+08:00 (Perth) == 2026-06-11T02:00:00Z. + fakeCalcom(t, "Australia/Perth", func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{ + "status":"success", + "data":{"2026-06-11":[{"start":"2026-06-11T10:00:00+08:00"}]} + }`) + }) + h, _ := newKeyedHandler(t) + + req := httptest.NewRequest(http.MethodGet, + "/slots?username=alice&eventType=30min&date=2026-06-11&blockId=b1", nil) + rec := httptest.NewRecorder() + h.HandleGetSlots(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) + } + + m := formHxGetRe.FindStringSubmatch(rec.Body.String()) + if m == nil { + t.Fatalf("no slot button hx-get to /form found in body: %q", rec.Body.String()) + } + // The browser reads the attribute via the DOM, which unescapes & → &. + u, err := url.Parse(html.UnescapeString(m[1])) + if err != nil { + t.Fatalf("parse slot form URL %q: %v", m[1], err) + } + + // u.Query() decodes exactly as net/http would on the wire — this is where a + // raw `+` becomes a space. + got := u.Query().Get("start") + parsed, err := time.Parse(time.RFC3339, got) + if err != nil { + t.Fatalf("slot start %q is not a valid ISO 8601 string after URL parsing (Cal.com would reject it): %v", got, err) + } + if want := time.Date(2026, 6, 11, 2, 0, 0, 0, time.UTC); !parsed.Equal(want) { + t.Errorf("slot start instant: got %s, want %s", parsed.UTC(), want) + } +}