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
|
||||
|
||||
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.8
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/go-chi/chi/v5 v5.3.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/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.8 h1:rPgO2tz8b5aVJFURIA5LrJhY53r/9c+O6BJl38h8nJQ=
|
||||
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/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
|
||||
@ -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,
|
||||
}})
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
name = "calcomblock"
|
||||
display_name = "Cal.com Booking"
|
||||
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."
|
||||
kind = "plugin"
|
||||
categories = ["forms"]
|
||||
@ -11,3 +11,5 @@ allowed_hosts = ["api.cal.com"]
|
||||
|
||||
[compatibility]
|
||||
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);
|
||||
// add parent to transaction.changed
|
||||
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
|
||||
this.delete(transaction);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user