Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fde4da3cb8 | |||
| 1acd9540bf | |||
| 7c93491063 | |||
| 5396956f7e |
4
go.mod
4
go.mod
@ -1,9 +1,9 @@
|
|||||||
module git.dev.alexdunmow.com/block/calcomblock
|
module git.dev.alexdunmow.com/block/calcomblock
|
||||||
|
|
||||||
go 1.26.4
|
go 1.26.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.dev.alexdunmow.com/block/pluginsdk v0.3.5
|
git.dev.alexdunmow.com/block/pluginsdk v0.3.8
|
||||||
github.com/a-h/templ v0.3.1020
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -1,7 +1,7 @@
|
|||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
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.8 h1:rPgO2tz8b5aVJFURIA5LrJhY53r/9c+O6BJl38h8nJQ=
|
||||||
git.dev.alexdunmow.com/block/pluginsdk v0.3.5/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
|
git.dev.alexdunmow.com/block/pluginsdk v0.3.8/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
|
|||||||
@ -15,10 +15,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/auth"
|
"git.dev.alexdunmow.com/block/pluginsdk/auth"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/rbac"
|
"git.dev.alexdunmow.com/block/pluginsdk/rbac"
|
||||||
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nyaruka/phonenumbers"
|
"github.com/nyaruka/phonenumbers"
|
||||||
)
|
)
|
||||||
@ -500,7 +500,11 @@ func (h *CalcomHandler) HandleGetSlots(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
timed = append(timed, timedSlot{at: local, slot: TimeSlot{
|
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"),
|
DisplayTime: local.Format("3:04 PM"),
|
||||||
DateKey: dateKey,
|
DateKey: dateKey,
|
||||||
}})
|
}})
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
name = "calcomblock"
|
name = "calcomblock"
|
||||||
display_name = "Cal.com Booking"
|
display_name = "Cal.com Booking"
|
||||||
scope = "@ninja"
|
scope = "@ninja"
|
||||||
version = "2.0.12"
|
version = "2.0.15"
|
||||||
description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver."
|
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"
|
kind = "plugin"
|
||||||
categories = ["forms"]
|
categories = ["forms"]
|
||||||
@ -11,3 +11,5 @@ allowed_hosts = ["api.cal.com"]
|
|||||||
|
|
||||||
[compatibility]
|
[compatibility]
|
||||||
block_core = ">=0.3.4"
|
block_core = ">=0.3.4"
|
||||||
|
plugin_sdk = ">=0.3.7 <0.4.0"
|
||||||
|
admin_api = ">=0.1.2"
|
||||||
|
|||||||
64
slot_start_encoding_test.go
Normal file
64
slot_start_encoding_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
web/dist/assets/index-BSkdT-DR.js
vendored
2
web/dist/assets/index-BSkdT-DR.js
vendored
@ -16186,7 +16186,7 @@ let Item$1 = class Item extends AbstractStruct {
|
|||||||
this.content.integrate(transaction, this);
|
this.content.integrate(transaction, this);
|
||||||
// add parent to transaction.changed
|
// add parent to transaction.changed
|
||||||
addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub);
|
addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub);
|
||||||
if (/** @type {AbstractType<any>} */ (this.parent._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) {
|
if (/** @type {AbstractType<any>} */ ((this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) {
|
||||||
// delete if parent is deleted or if this is not the current attribute value of parent
|
// delete if parent is deleted or if this is not the current attribute value of parent
|
||||||
this.delete(transaction);
|
this.delete(transaction);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user