calcomblock/handler.go
Alex Dunmow bd7f9fad15 fix: flat absolute routes + declare api.cal.com egress (2.0.7)
Register the chi routes as flat absolute paths (httpBase+"/slots" …)
instead of a nested r.Route mount: the wasm host forwards the full
original path with no prefix strip, so the nested mount's RoutePath
rebasing never matched and 404'd the entire HTTP surface.

Declare allowed_hosts = ["api.cal.com"] so the ADR-0023 install consent
dialog asks the admin to grant egress; without it every Cal.com call was
denied at runtime (ABI_ERROR_CODE_EGRESS_DENIED, no egress grant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:38:31 +08:00

1157 lines
43 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"encoding/json"
"log"
"log/slog"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"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"
)
// blockContentResolver answers the server-authoritative question "does a
// booking for this Cal.com username + event-type slug require a captcha token?"
// by scanning currently-published block content — NEVER a client-supplied
// blockId. *poolQuerier satisfies it in production (per-call transaction over
// plugin.Pool); tests inject a fake. Nil in contexts without a DB (editor
// preview, direct-handler unit tests) — bookingRequiresCaptcha then reports
// false (baseline honeypot + rate limit still apply).
type blockContentResolver interface {
CalcomBookingRequiresCaptcha(ctx context.Context, username, eventTypeSlug string) (bool, error)
}
// requireAdmin gates a sub-router to authenticated CMS admins.
// The outer server runs OptionalAuth which populates the context with
// auth.Claims when the request carries a valid admin session cookie or
// Bearer token; otherwise context is empty. Plugin admin endpoints are
// otherwise publicly mounted at /api/plugins/calcomblock/*, so this guard
// is the only thing standing between the open internet and the tenant's
// Cal.com API key / webhook secret.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetUserFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !rbac.HasPermission(rbac.Role(claims.Role), rbac.RoleAdmin) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// emailRE is the relaxed email validation pattern used as a cheap filter for
// obvious junk. Cal.com does its own validation server-side.
var emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
// bookingsPerHourPerIP is the rate-limit ceiling for the public /book endpoint.
const bookingsPerHourPerIP = 10
// controlFields are transport / explicitly-handled form values (attendee
// name+email, hidden routing inputs, the honeypot). They are never forwarded as
// Cal.com bookingFieldsResponses.
var controlFields = map[string]bool{
"blockId": true,
"username": true,
"eventType": true,
"start": true,
"timezone": true,
"locale": true, // browser locale, used server-side to infer the phone dial code
"phoneCountry": true, // country-combobox selection (ISO code), drives the phone dial code
"bn_message_extra": true, // honeypot (deliberately non-semantic to dodge Chrome autofill)
"name": true,
"email": true,
"cap-token": true, // captcha verification token (server-read, never forwarded to Cal.com)
}
// phoneFieldSlugs are the Cal.com system slugs that carry the attendee phone
// number. The default phone field's slug is `attendeePhoneNumber` (NOT "phone"),
// so a value under any of these routes to attendee.phoneNumber — never to
// bookingFieldsResponses, where it would both fail phone validation and orphan
// the attendee's number.
var phoneFieldSlugs = map[string]bool{
"attendeePhoneNumber": true,
"phone": true,
"smsReminderNumber": true,
"number": true,
}
// systemDropSlugs are Cal.com system fields with no valid home in a new-booking
// bookingFieldsResponses payload (custom-field-only per the v2 schema):
// - location: a structured top-level object; left unset so Cal.com uses the
// event type's configured location.
// - rescheduleReason: reschedule-only — invalid on a create.
// - title: system-managed.
//
// `guests` is handled separately (top-level array); `name`/`email` are read as
// attendee fields via controlFields.
var systemDropSlugs = map[string]bool{
"location": true,
"rescheduleReason": true,
"title": true,
}
// bookingFormPayload is the Cal.com-bound projection of a submitted booking form.
type bookingFormPayload struct {
Phone string // -> attendee.phoneNumber (E.164-normalized)
Guests []string // -> top-level guests[]
Responses map[string]any // -> bookingFieldsResponses (custom fields only)
}
// routeBookingForm splits a submitted booking form into Cal.com's typed slots
// per the v2 POST /bookings schema. Phone-typed fields go to attendee.phoneNumber,
// `guests` becomes the top-level email array, and only genuine custom fields land
// in bookingFieldsResponses. Empty values are dropped entirely — Cal.com rejects
// empty/mistyped system responses, which is the root cause of the silent booking
// failures this routing fixes. region (an ISO 3166-1 alpha-2 code, e.g. "AU") is
// used to convert national-format attendee phone numbers to E.164.
func routeBookingForm(form url.Values, region string) bookingFormPayload {
out := bookingFormPayload{Responses: map[string]any{}}
for k, vs := range form {
switch {
case controlFields[k]:
// attendee name/email + transport fields handled elsewhere
case phoneFieldSlugs[k]:
if out.Phone == "" {
out.Phone = normalizePhone(firstNonEmpty(vs), region)
}
case k == "guests":
out.Guests = append(out.Guests, splitGuestEmails(vs)...)
case systemDropSlugs[k]:
// no valid custom-response home; dropped
default:
// Custom field. Drop empties; collapse to a scalar or a JSON array
// for multi-valued inputs (checkboxes / multiselect).
switch nonEmpty := dropEmptyValues(vs); len(nonEmpty) {
case 0:
case 1:
out.Responses[k] = nonEmpty[0]
default:
out.Responses[k] = toAnySlice(nonEmpty)
}
}
}
if len(out.Responses) == 0 {
out.Responses = nil
}
if len(out.Guests) == 0 {
out.Guests = nil
}
return out
}
// normalizePhone converts a submitted phone number to the E.164 form Cal.com
// requires (it rejects national-format numbers with `{attendeePhoneNumber}
// invalid_number`). It parses via libphonenumber using `region` (an ISO 3166-1
// alpha-2 code, e.g. "AU") as the default country, which correctly applies each
// country's trunk-prefix rules — Italy keeps its leading 0, Russia's trunk is 8,
// most others drop a leading 0. Numbers already in international form (leading
// "+") parse regardless of region. When parsing fails or the number is invalid
// (unknown region, partial input) the separator-stripped input is returned
// best-effort and Cal.com does its own validation. Empty input yields "".
func normalizePhone(raw, region string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
region = strings.ToUpper(strings.TrimSpace(region))
if _, ok := countryByCode(region); !ok {
region = "" // unknown region; libphonenumber then relies on a leading "+"
}
if num, err := phonenumbers.Parse(raw, region); err == nil && phonenumbers.IsValidNumber(num) {
return phonenumbers.Format(num, phonenumbers.E164)
}
return stripPhoneSeparators(raw)
}
// stripPhoneSeparators removes spaces (incl. non-breaking), tabs and common
// punctuation used to format phone numbers, leaving digits and a leading "+".
func stripPhoneSeparators(raw string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(raw) {
switch r {
case ' ', ' ', '\t', '-', '(', ')', '.', '/':
// drop separators
default:
b.WriteRune(r)
}
}
return b.String()
}
// splitGuestEmails flattens Cal.com's multiemail `guests` field — rendered by the
// widget as a single text input — into individual addresses. Accepts values
// separated by commas, semicolons, whitespace or newlines; trims and drops blanks.
func splitGuestEmails(vs []string) []string {
var out []string
for _, v := range vs {
for _, part := range strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == ' ' || r == '\t'
}) {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
}
return out
}
// firstNonEmpty returns the first trimmed non-empty value, or "".
func firstNonEmpty(vs []string) string {
for _, v := range vs {
if t := strings.TrimSpace(v); t != "" {
return t
}
}
return ""
}
// dropEmptyValues returns vs with blank / whitespace-only entries removed.
func dropEmptyValues(vs []string) []string {
out := make([]string, 0, len(vs))
for _, v := range vs {
if strings.TrimSpace(v) != "" {
out = append(out, v)
}
}
return out
}
// toAnySlice widens a []string to []any for JSON array encoding.
func toAnySlice(ss []string) []any {
out := make([]any, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}
// CalcomHandler handles Cal.com booking API endpoints
type CalcomHandler struct {
settings *SettingsManager
limiter *RateLimiter
appURL string
// blockConfig resolves a block's stored content by blockId so captcha
// enforcement reads the server-authoritative captchaEnabled flag rather than
// trusting the POST body. nil in editor-preview / direct-handler unit tests,
// where blockCaptchaEnabled reports false.
blockConfig blockContentResolver
// rt is the host-mediated egress transport (deps.OutboundHTTP, ADR 0023)
// every Cal.com client is built with. nil in native/DESCRIBE/test builds,
// where NewCalcomClient falls back to the default transport.
rt http.RoundTripper
}
// NewCalcomHandler creates a new Cal.com handler. appURL is the externally
// reachable origin used to render the tenant-specific webhook URL in the
// admin settings panel (e.g. "https://acme.blockninjacms.com").
func NewCalcomHandler(settings *SettingsManager, limiter *RateLimiter, appURL string) *CalcomHandler {
return &CalcomHandler{settings: settings, limiter: limiter, appURL: appURL}
}
// NewCalcomRouter creates the HTTP router for the Cal.com plugin.
// deps carries the CMS-provided Crypto and database Pool used to persist
// and encrypt the Cal.com API key + webhook secret.
func NewCalcomRouter(deps plugin.CoreServices) http.Handler {
r := chi.NewRouter()
// Context does not cross the wasm ABI: requireAdmin's claims exist only if
// the guest rebuilds them from the host-stamped X-Bn-Verified-* headers.
// Without this middleware every admin endpoint 401s for real admins (the
// settings panel could not save the API key — 2.0.3 regression).
r.Use(auth.TrustedHeaderMiddleware)
// Settings persistence goes through the SDK settings capabilities: under the
// wasm sandbox the plugin's Postgres role cannot touch the CMS `settings`
// table (public schema) directly, so the host performs the DB work.
pq := &capabilityQuerier{settings: deps.Settings, updater: deps.SettingsUpdater}
settings := NewSettingsManager(deps.Crypto, pq)
// Routes are mounted at startup, before any page render — stash the
// manager so CalcomBookingFunc (which renders with no request and no
// handler) can resolve the site/event timezone for the initial grid.
renderSettings = settings
limiter := NewRateLimiter(bookingsPerHourPerIP)
helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale)
h := NewCalcomHandler(settings, limiter, deps.AppURL)
// Route every Cal.com call through the host-mediated egress transport. nil
// under native/DESCRIBE builds (deps has no host); the client then uses the
// default transport, which only the non-wasm paths can reach.
h.rt = deps.OutboundHTTP
// blockConfig resolves the server-authoritative captcha requirement from
// published block content via the content.published_block_configs
// capability (pluginsdk >= v0.2.4) — the sandboxed plugin role cannot read
// page_block_snapshots itself. deps.Content is nil in native/DESCRIBE
// builds; blockConfig then stays nil and bookingRequiresCaptcha reports
// false (honeypot + per-IP rate limit remain the baseline).
if deps.Content != nil {
h.blockConfig = publishedBlockResolver{source: deps.Content}
}
// Routes are registered ABSOLUTE on a flat chi router: the wasm host forwards
// the FULL original path (r.URL.Path, no prefix strip), so the guest matches
// on the complete path. A nested r.Route(httpBase, …) instead relies on chi
// Mount's RoutePath rebasing, which the raw-path forwarder never populates —
// mirror testplugin's httpBase convention (flat + absolute) so the surface
// resolves regardless of how the host mounts the forwarder.
// Public booking endpoints — reachable by anonymous visitors.
r.Get(httpBase+"/slots", h.HandleGetSlots)
r.Get(httpBase+"/form", h.HandleGetForm)
r.Post(httpBase+"/book", h.HandleCreateBooking)
r.Post(httpBase+"/cancel", h.HandleCancelBooking)
r.Get(httpBase+"/reset", h.HandleReset)
r.Get(httpBase+"/date-grid", h.HandleGetDateGrid)
// Webhook receiver — HMAC-verified inside the handler, so no admin gate.
wh := NewWebhookHandler(settings, slog.Default())
r.Post(httpBase+"/webhook", wh.Handle)
// Admin endpoints — gated by requireAdmin. The plugin HTTP mount has no
// outer admin middleware, so this is the only line keeping the API key
// + webhook secret + Cal.com proxy out of attackers' hands.
r.Group(func(admin chi.Router) {
admin.Use(requireAdmin)
admin.Get(httpBase+"/settings", h.HandleGetSettings)
admin.Post(httpBase+"/settings", h.HandleSaveSettings)
admin.Post(httpBase+"/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret)
admin.Post(httpBase+"/test", h.HandleTestConnection)
admin.Get(httpBase+"/event-types", h.HandleListEventTypes)
})
return r
}
// eventTZCache memoizes the meeting timezone per (username, event slug) so
// the public endpoints don't pay two Cal.com round-trips per request. 15
// minutes is short enough that an availability-timezone change propagates
// quickly, long enough to amortize bursts. Failures are not cached — the next
// request retries. Package-level (not per-handler) so CalcomBookingFunc,
// which renders without a handler, can peek warm entries.
var eventTZCache = struct {
sync.Mutex
m map[string]eventTZEntry
}{m: map[string]eventTZEntry{}}
type eventTZEntry struct {
tz string
fetched time.Time
}
const eventTZTTL = 15 * time.Minute
func eventTZKey(username, eventType string) string { return username + "|" + eventType }
// peekEventTZ returns the cached meeting timezone, or "" when cold/expired.
// Never fetches — safe to call from the page-render path.
func peekEventTZ(username, eventType string) string {
eventTZCache.Lock()
defer eventTZCache.Unlock()
e, ok := eventTZCache.m[eventTZKey(username, eventType)]
if !ok || time.Since(e.fetched) > eventTZTTL {
return ""
}
return e.tz
}
func storeEventTZ(username, eventType, tz string) {
eventTZCache.Lock()
defer eventTZCache.Unlock()
eventTZCache.m[eventTZKey(username, eventType)] = eventTZEntry{tz: tz, fetched: time.Now()}
}
// resetEventTZCache empties the cache. Test seam — withCalcomBaseURL calls it
// so per-test fake servers can't leak timezones into each other.
func resetEventTZCache() {
eventTZCache.Lock()
defer eventTZCache.Unlock()
eventTZCache.m = map[string]eventTZEntry{}
}
// fetchEventTimezone resolves the meeting's timezone from Cal.com: the event
// type's pinned availability schedule when one is set, else the account's
// default schedule. Returns "" when any hop fails — callers then fall down
// the site-setting ladder rather than guessing.
func fetchEventTimezone(ctx context.Context, rt http.RoundTripper, apiKey, username, eventType string) string {
client := NewCalcomClient(apiKey, rt)
schedules, err := client.ListSchedules(ctx)
if err != nil || len(schedules) == 0 {
log.Printf("calcom: list schedules for %s/%s: %v", username, eventType, err)
return ""
}
var scheduleID *int64
if et, err := client.GetEventType(ctx, username, eventType); err == nil {
scheduleID = et.ScheduleID
} else {
log.Printf("calcom: get event type %s/%s for timezone: %v (using default schedule)", username, eventType, err)
}
var fallback string
for _, s := range schedules {
if scheduleID != nil && s.ID == *scheduleID {
return s.TimeZone
}
if s.IsDefault && fallback == "" {
fallback = s.TimeZone
}
}
return fallback
}
// eventLocation resolves the timezone every booking surface renders in: the
// MEETING's timezone (its Cal.com availability schedule), else the site-wide
// Localization setting, else UTC. The visitor's browser timezone is
// deliberately not consulted: availability like "MonFri 86" is a fact about
// the host's calendar, and making the display depend on per-visitor client
// state is what produced the bidbuddy incidents (2026-06-10) — cached pages
// shipped a UTC sentinel and visitors saw 12:00 AM slots. One zone, server
// resolved, every surface.
func (h *CalcomHandler) eventLocation(ctx context.Context, apiKey, username, eventType string) (*time.Location, string) {
tz := peekEventTZ(username, eventType)
if tz == "" && apiKey != "" && username != "" && eventType != "" {
if tz = fetchEventTimezone(ctx, h.rt, apiKey, username, eventType); tz != "" {
storeEventTZ(username, eventType, tz)
}
}
return h.settings.resolveLocation(ctx, tz)
}
// HandleGetSlots fetches available time slots from Cal.com via CalcomClient.
func (h *CalcomHandler) HandleGetSlots(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
apiKey, err := h.settings.GetAPIKey(r.Context())
if err != nil {
h.renderError(w, "Failed to load Cal.com settings", "", true)
return
}
if apiKey == "" {
h.renderError(w, "Cal.com integration is not configured", "", true)
return
}
username := r.URL.Query().Get("username")
eventType := r.URL.Query().Get("eventType")
dateStr := r.URL.Query().Get("date")
blockID := r.URL.Query().Get("blockId")
if username == "" || eventType == "" || dateStr == "" {
h.renderError(w, "Missing required parameters", blockID, true)
return
}
loc, tzName := h.eventLocation(r.Context(), apiKey, username, eventType)
// The day window is midnight-to-midnight in the MEETING's timezone — not
// UTC. Built at UTC midnight, a Perth event's "June 11" window covers
// June 11 8:00 AM June 12 8:00 AM local, pulling next-day slots under
// the wrong heading.
dayStart, err := time.ParseInLocation("2006-01-02", dateStr, loc)
if err != nil {
h.renderError(w, "Invalid date format", blockID, true)
return
}
dayEnd := dayStart.AddDate(0, 0, 1)
client := NewCalcomClient(apiKey, h.rt)
slotsResp, err := client.GetSlots(
r.Context(),
username,
eventType,
dayStart.Format(time.RFC3339),
dayEnd.Format(time.RFC3339),
tzName,
)
if err != nil {
log.Printf("calcom: get slots: %v", err)
h.renderError(w, "Failed to fetch slots from Cal.com", blockID, true)
return
}
type timedSlot struct {
at time.Time
slot TimeSlot
}
var timed []timedSlot
for dateKey, timeSlots := range slotsResp.Data {
for _, slot := range timeSlots {
ts := slot.When()
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
local := t.In(loc)
// Cal.com's end bound is date-granular: ask for one day and the
// entire end day comes back too. Clamp to the requested local day.
if local.Before(dayStart) || !local.Before(dayEnd) {
continue
}
timed = append(timed, timedSlot{at: local, slot: TimeSlot{
Start: ts,
DisplayTime: local.Format("3:04 PM"),
DateKey: dateKey,
}})
}
}
// Slots are assembled by ranging a map — without a sort the order is luck.
sort.Slice(timed, func(i, j int) bool { return timed[i].at.Before(timed[j].at) })
slots := make([]TimeSlot, 0, len(timed))
for _, ts := range timed {
slots = append(slots, ts.slot)
}
config := SlotConfig{
BlockID: blockID,
Username: username,
EventType: eventType,
SelectedDate: dayStart.Format("Monday, January 2, 2006"),
}
var buf bytes.Buffer
if err := CalcomTimeSlots(slots, config).Render(r.Context(), &buf); err != nil {
h.renderError(w, "Failed to render slots", blockID, true)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("failed to write Cal.com slots response: %v", err)
}
}
// HandleGetForm renders the booking form, including any custom bookingFields
// configured on the Cal.com event type.
func (h *CalcomHandler) HandleGetForm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
blockID := r.URL.Query().Get("blockId")
startTime := r.URL.Query().Get("start")
dateStr := r.URL.Query().Get("date")
username := r.URL.Query().Get("username")
eventType := r.URL.Query().Get("eventType")
apiKey, apiKeyErr := h.settings.GetAPIKey(r.Context())
loc, tzName := h.eventLocation(r.Context(), apiKey, username, eventType)
t, err := time.Parse(time.RFC3339, startTime)
displayTime := startTime
if err == nil {
displayTime = t.In(loc).Format("3:04 PM")
}
displayDate := dateStr
if d, err := time.Parse("2006-01-02", dateStr); err == nil {
displayDate = d.Format("Monday, January 2, 2006")
}
var bookingFields []BookingField
if apiKeyErr == nil && apiKey != "" && username != "" && eventType != "" {
client := NewCalcomClient(apiKey, h.rt)
et, etErr := client.GetEventType(r.Context(), username, eventType)
if etErr != nil {
// Fall back to default fields so a transient Cal.com error doesn't
// strand the visitor on a half-rendered form.
log.Printf("calcom: get event type %q/%q: %v", username, eventType, etErr)
} else {
bookingFields = et.BookingFields
}
}
formConfig := FormConfig{
BlockID: blockID,
Username: username,
EventType: eventType,
SelectedDate: displayDate,
SelectedTime: displayTime,
StartTime: startTime,
TimeZone: tzName,
BookingFields: bookingFields,
DefaultCountryCode: resolveRegion(r.URL.Query().Get("locale"), tzName),
// Same server-authoritative source HandleCreateBooking enforces against,
// so the widget is shown exactly when a token will be required — keyed on
// the username+eventType, not the client-supplied blockId.
CaptchaEnabled: h.bookingRequiresCaptcha(r.Context(), username, eventType),
}
var buf bytes.Buffer
if err := CalcomBookingForm(formConfig).Render(r.Context(), &buf); err != nil {
h.renderError(w, "Failed to render form", blockID, false)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("failed to write Cal.com booking form response: %v", err)
}
}
// bookingRequiresCaptcha reports whether a booking for this Cal.com username +
// event-type slug requires a captcha token. The decision is server-authoritative:
// it is true iff ANY currently-published calcom:booking block for that
// username+eventType has captchaEnabled=true in its stored content — resolved
// from published content, NEVER from the client-supplied blockId, so a bot
// cannot strip the requirement by omitting or forging the id. Returns false when
// there is no resolver (editor preview / DB-less unit tests), when no published
// block matches (draft-only ⇒ baseline honeypot + rate limit still apply), or on
// a resolver error (logged; a transient DB blip must not block every booking —
// the honeypot + per-IP rate limit remain in force). This is the same source the
// block render (HandleGetForm) reads, keeping "show the widget" and "enforce the
// widget" in lockstep.
func (h *CalcomHandler) bookingRequiresCaptcha(ctx context.Context, username, eventTypeSlug string) bool {
if h.blockConfig == nil {
return false
}
required, err := h.blockConfig.CalcomBookingRequiresCaptcha(ctx, username, eventTypeSlug)
if err != nil {
log.Printf("calcom: resolve captcha requirement for %q/%q: %v", username, eventTypeSlug, err)
return false
}
return required
}
// HandleCreateBooking creates a booking via Cal.com using CalcomClient.
func (h *CalcomHandler) HandleCreateBooking(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
apiKey, err := h.settings.GetAPIKey(r.Context())
if err != nil {
h.renderError(w, "Failed to load Cal.com settings", "", false)
return
}
if apiKey == "" {
h.renderError(w, "Cal.com integration is not configured", "", false)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, "Invalid form data", r.FormValue("blockId"), false)
return
}
blockID := r.FormValue("blockId")
// Honeypot: real users never fill this hidden field (deliberately
// non-semantic name to dodge Chrome autofill heuristics). Return a fake
// confirmation to bots so they think they succeeded — never reach Cal.com.
if strings.TrimSpace(r.FormValue("bn_message_extra")) != "" {
var buf bytes.Buffer
if err := CalcomConfirmation(BookingResult{Success: true}, blockID).Render(r.Context(), &buf); err != nil {
h.renderError(w, "Failed to render confirmation", blockID, false)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("calcom: write honeypot response: %v", err)
}
return
}
username := r.FormValue("username")
eventType := r.FormValue("eventType")
startTime := r.FormValue("start")
// Cal.com's bookings endpoint requires `start` in UTC. Slot starts already
// arrive as UTC (…Z); normalize defensively so a future slots-format change
// (e.g. a local UTC offset) can never send a non-UTC start upstream.
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
startTime = t.UTC().Format(time.RFC3339)
}
name := r.FormValue("name")
email := r.FormValue("email")
// Cheap validation BEFORE the rate-limit allowance is consumed so a flood
// of garbage-email requests can't drain a victim IP's quota (spec § 3.8).
if name == "" || email == "" {
h.renderError(w, "Name and email are required", blockID, false)
return
}
if !emailRE.MatchString(email) {
h.renderError(w, "Invalid email address", blockID, false)
return
}
ip := helpers.GetRealIP(r)
if h.limiter != nil && !h.limiter.Allow(ip) {
w.WriteHeader(http.StatusTooManyRequests)
h.renderError(w, "Too many bookings from this network. Please try again later.", blockID, false)
return
}
// Captcha enforcement. Layered ON TOP of the honeypot + per-IP rate limit
// above (verified after both, per the shared captcha design). The
// authoritative source is whether any currently-published calcom:booking
// block for this username+eventType has captchaEnabled=true — resolved from
// published content, never the POST body/blockId — so a bot cannot strip the
// requirement by omitting or forging blockId. Verification is HOST-side: a
// guest cannot hold the host's stateful captcha server, so the host consumes
// the cap-token and stamps the unforgeable X-Bn-Verified-Captcha trusted
// header (pluginsdk/auth). When captcha IS required we fail CLOSED: no
// stamp (invalid token, or the instance captcha server failed at startup)
// rejects rather than allows. On failure we render the same error partial
// as other validation failures (not a 500), so the visitor can re-solve
// and retry.
if h.bookingRequiresCaptcha(r.Context(), username, eventType) {
if !auth.CaptchaVerified(r.Header) {
h.renderError(w, "Captcha check failed, please try again.", blockID, false)
return
}
}
loc, tzName := h.eventLocation(r.Context(), apiKey, username, eventType)
// Resolve the phone region used to convert national numbers to E.164 (Cal.com
// rejects national-format numbers as invalid_number). The visitor's explicit
// country-combobox choice (phoneCountry) wins; otherwise fall back to the
// timezone/locale detection that seeded the combobox default.
region := strings.ToUpper(strings.TrimSpace(r.FormValue("phoneCountry")))
if _, ok := countryByCode(region); !ok {
region = resolveRegion(r.FormValue("locale"), tzName)
}
payload := routeBookingForm(r.Form, region)
client := NewCalcomClient(apiKey, h.rt)
br := BookingRequest{
EventTypeSlug: eventType,
Username: username,
Start: startTime,
Attendee: BookingAttendee{
Name: name,
Email: email,
Phone: payload.Phone,
TimeZone: tzName,
},
BookingFieldsResponses: payload.Responses,
Guests: payload.Guests,
}
booking, err := client.CreateBooking(r.Context(), br)
if err != nil {
// Keep the FULL technical cause server-side (unchanged), but never
// surface it. Classify into curated, user-actionable copy: Tier-1
// specific messages for visitor-fixable failures (with a retry control
// where re-picking a slot is the fix), else the admin-authored Tier-2
// message (a hidden form field, defaulted when blank).
log.Printf("calcom: create booking: %v", err)
adminMessage := strings.TrimSpace(r.FormValue("unavailableMessage"))
if adminMessage == "" {
adminMessage = DefaultUnavailableMessage
}
res := classifyBookingError(err, adminMessage)
h.renderError(w, res.message, blockID, res.canRetry)
return
}
// startTime was normalized to UTC for the Cal.com API above; the
// confirmation must render in the meeting's timezone, not the UTC instant.
formattedDateTime := startTime
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
formattedDateTime = t.In(loc).Format("Monday, January 2, 2006 at 3:04 PM")
}
result := BookingResult{
Success: true,
BookingID: booking.Data.UID,
FormattedDateTime: formattedDateTime,
Email: email,
MeetingLink: booking.Data.MeetingLink(),
}
var buf bytes.Buffer
if err := CalcomConfirmation(result, blockID).Render(r.Context(), &buf); err != nil {
h.renderError(w, "Failed to render confirmation", blockID, false)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("failed to write Cal.com confirmation response: %v", err)
}
}
// HandleCancelBooking cancels a previously-created booking and renders the
// "cancelled" partial (which OOB-restores the date grid so the visitor can
// rebook). It is the server half of the localStorage-backed "manage your
// booking" panel: the widget script populates the hidden `uid` input from the
// visitor's own localStorage and POSTs here.
//
// SECURITY MODEL (deliberate): cancellation is authorized by the booking UID
// alone — the same capability model as Cal.com's own emailed cancel link. The
// UID is an unguessable Cal.com identifier, and the only place it is stored
// client-side is the booking visitor's own localStorage, so a visitor can only
// ever cancel a booking they made. There is no per-visitor auth on the public
// widget to bind it to (bookings are anonymous), so the IP rate limit is the
// abuse guard against someone brute-forcing UIDs. We reuse the booking limiter:
// a cancel is a write against Cal.com just like a booking, so the two share one
// per-IP budget and a flood of cancel attempts can't drain into unlimited
// upstream calls. Cal.com's raw error text is never surfaced — only curated
// copy — and the full cause is logged server-side.
func (h *CalcomHandler) HandleCancelBooking(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
apiKey, err := h.settings.GetAPIKey(r.Context())
if err != nil {
h.renderError(w, "Failed to load Cal.com settings", "", false)
return
}
if apiKey == "" {
h.renderError(w, "Cal.com integration is not configured", "", false)
return
}
if err := r.ParseForm(); err != nil {
h.renderError(w, "Invalid form data", r.FormValue("blockId"), false)
return
}
blockID := r.FormValue("blockId")
uid := strings.TrimSpace(r.FormValue("uid"))
if uid == "" {
h.renderError(w, "Missing booking reference", blockID, false)
return
}
// Rate-limit by IP (shared booking budget) — the abuse guard for UID-based
// cancellation. Consumed only after the cheap uid presence check so a flood
// of empty requests can't drain a victim IP's quota.
ip := helpers.GetRealIP(r)
if h.limiter != nil && !h.limiter.Allow(ip) {
w.WriteHeader(http.StatusTooManyRequests)
h.renderError(w, "Too many requests from this network. Please try again later.", blockID, false)
return
}
client := NewCalcomClient(apiKey, h.rt)
if err := client.CancelBooking(r.Context(), uid, "Cancelled by attendee"); err != nil {
// Full technical cause stays server-side; the visitor sees curated copy.
log.Printf("calcom: cancel booking %q: %v", uid, err)
h.renderError(w, "Sorry, we couldn't cancel that booking. Please try again or use our contact form.", blockID, true)
return
}
var buf bytes.Buffer
if err := CalcomCancelled(blockID).Render(r.Context(), &buf); err != nil {
h.renderError(w, "Failed to render cancellation", blockID, false)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("failed to write Cal.com cancellation response: %v", err)
}
}
// HandleGetDateGrid returns a freshly rendered date-grid partial for a given
// weekStart + weeks window. Wired to the prev/next month-nav buttons, this
// is the server-side endpoint that drives month navigation; the response
// innerHTML-swaps into #calcom-date-grid-{id}.
func (h *CalcomHandler) HandleGetDateGrid(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
blockID := q.Get("blockId")
username := q.Get("username")
eventType := q.Get("eventType")
weeks, _ := strconv.Atoi(q.Get("weeks"))
if weeks <= 0 {
weeks = 2
}
if weeks > 8 {
weeks = 8 // matches the editor's max
}
// "Today" — and therefore the earliest reachable window — is a date on
// the MEETING's calendar, not the server's. A UTC server clock is a day
// behind a Perth schedule until 08:00 AWST; clamping to it would let the
// grid start on a day the host can no longer be booked.
apiKey, _ := h.settings.GetAPIKey(r.Context())
loc, _ := h.eventLocation(r.Context(), apiKey, username, eventType)
todayStr := time.Now().In(loc).Format("2006-01-02")
today, _ := time.ParseInLocation("2006-01-02", todayStr, loc)
start, err := time.ParseInLocation("2006-01-02", q.Get("weekStart"), loc)
if err != nil {
start = today
}
// Visitors cannot navigate before today.
if start.Before(today) {
start = today
}
config := BookingConfig{
BlockID: blockID,
Username: username,
EventTypeSlug: eventType,
WeeksToShow: weeks,
WeekStart: start.Format("2006-01-02"),
Today: todayStr,
RangeLabel: formatRangeLabel(start, weeks),
CanGoBack: start.After(today),
Dates: generateDateGridFrom(start, weeks, todayStr),
}
var buf bytes.Buffer
if err := calcomDateGrid(config).Render(r.Context(), &buf); err != nil {
log.Printf("calcom: render date grid: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("failed to write Cal.com date-grid response: %v", err)
}
}
// HandleReset rewinds the booking widget to its initial state. The body
// replaces #calcom-slots-{id} with nothing and includes hx-swap-oob clears
// for the form region + a step-indicator rewind — so a visitor pressing
// "Change date" no longer has a stale form lingering from a previous slot.
func (h *CalcomHandler) HandleReset(w http.ResponseWriter, r *http.Request) {
blockID := r.URL.Query().Get("blockId")
var buf bytes.Buffer
if err := CalcomReset(blockID).Render(r.Context(), &buf); err != nil {
log.Printf("calcom: render reset: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("failed to write Cal.com reset response: %v", err)
}
}
// HandleGetSettings returns the current Cal.com settings: API key (masked) +
// webhook URL + masked webhook secret + last-event status. Secrets are never
// exposed verbatim.
func (h *CalcomHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey, err := h.settings.GetAPIKey(ctx)
if err != nil {
writeServerError(w, err, "Failed to load settings", "calcomblock.HandleGetSettings")
return
}
secret, _ := h.settings.GetWebhookSecret(ctx)
store, _ := h.settings.GetSettings(ctx)
username, _ := h.settings.GetUsername(ctx)
// Self-heal: tenants whose API key was saved before this feature shipped
// have no cached username. Fire /me once on the first settings load and
// persist the result so subsequent loads are cache-hits.
if username == "" && apiKey != "" {
client := NewCalcomClient(apiKey, h.rt)
if me, err := client.GetMe(ctx); err != nil {
log.Printf("calcom: backfill /me in HandleGetSettings: %v", err)
} else {
username = me.Username
if err := h.settings.SetUsername(ctx, me.Username); err != nil {
log.Printf("calcom: persist backfilled username: %v", err)
}
}
}
resp := map[string]any{
"api_key_configured": apiKey != "",
"api_key_masked": maskSecret(apiKey),
"username": username,
"webhook_url": h.webhookURL(),
"webhook_secret_configured": secret != "",
"webhook_secret_masked": helpers.MaskSecret(secret),
"last_event_at": store["last_event_at"],
"last_event_type": store["last_event_type"],
}
writeJSON(w, resp)
}
// webhookURL builds the externally reachable webhook URL for this tenant. It
// reads the AppURL the CMS injected when the plugin was mounted.
func (h *CalcomHandler) webhookURL() string {
if h.appURL == "" {
return "/api/plugins/calcomblock/webhook"
}
return strings.TrimRight(h.appURL, "/") + "/api/plugins/calcomblock/webhook"
}
// HandleSaveSettings saves the Cal.com API key
func (h *CalcomHandler) HandleSaveSettings(w http.ResponseWriter, r *http.Request) {
var req struct {
APIKey string `json:"api_key"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
ctx := r.Context()
if err := h.settings.SetAPIKey(ctx, req.APIKey); err != nil {
writeServerError(w, err, "Failed to save settings", "calcomblock.HandleSaveSettings")
return
}
if req.APIKey == "" {
// Clearing the key also clears the cached identity so a future save
// with a fresh key doesn't reuse the previous owner's username.
if err := h.settings.SetUsername(ctx, ""); err != nil {
log.Printf("calcom: clear username on api-key removal: %v", err)
}
} else {
// Resolve the API key's owner so the block editor doesn't have to
// ask the admin to type their own username. Best-effort: if /me is
// down or rate-limited, the save still succeeds and the admin can
// refresh later via Test Connection.
client := NewCalcomClient(req.APIKey, h.rt)
if me, err := client.GetMe(ctx); err != nil {
log.Printf("calcom: /me lookup after save: %v", err)
} else if err := h.settings.SetUsername(ctx, me.Username); err != nil {
log.Printf("calcom: persist username after /me: %v", err)
}
// On the first save with an API key, generate a webhook secret so
// the admin has something to paste into Cal.com's webhook
// configuration immediately.
existing, _ := h.settings.GetWebhookSecret(ctx)
if existing == "" {
secret, err := generateWebhookSecret()
if err != nil {
writeServerError(w, err, "Failed to generate webhook secret", "calcomblock.HandleSaveSettings")
return
}
if err := h.settings.SetWebhookSecret(ctx, secret); err != nil {
writeServerError(w, err, "Failed to save webhook secret", "calcomblock.HandleSaveSettings")
return
}
}
}
writeJSON(w, map[string]any{
"success": true,
"api_key_masked": maskSecret(req.APIKey),
"api_key_configured": req.APIKey != "",
})
}
// HandleRotateWebhookSecret generates a new webhook signing secret and returns
// its masked form. Invalidates the previous secret immediately — Cal.com will
// reject subsequent retries with the old secret.
func (h *CalcomHandler) HandleRotateWebhookSecret(w http.ResponseWriter, r *http.Request) {
secret, err := generateWebhookSecret()
if err != nil {
writeServerError(w, err, "Failed to generate webhook secret", "calcomblock.HandleRotateWebhookSecret")
return
}
if err := h.settings.SetWebhookSecret(r.Context(), secret); err != nil {
writeServerError(w, err, "Failed to save webhook secret", "calcomblock.HandleRotateWebhookSecret")
return
}
writeJSON(w, map[string]any{
"success": true,
"webhook_secret_masked": helpers.MaskSecret(secret),
})
}
// HandleListEventTypes powers the block editor's event-type dropdown. The
// `username` query param scopes the lookup; an empty value returns the
// API-key owner's own event types.
func (h *CalcomHandler) HandleListEventTypes(w http.ResponseWriter, r *http.Request) {
apiKey, err := h.settings.GetAPIKey(r.Context())
if err != nil {
writeServerError(w, err, "Failed to read settings", "calcomblock.HandleListEventTypes")
return
}
if apiKey == "" {
writeJSON(w, map[string]any{"success": false, "error": "API key not configured"})
return
}
username := r.URL.Query().Get("username")
client := NewCalcomClient(apiKey, h.rt)
ets, err := client.ListEventTypes(r.Context(), username)
if err != nil {
log.Printf("calcom: list event types: %v", err)
writeJSON(w, map[string]any{"success": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"success": true, "event_types": ets})
}
// HandleTestConnection verifies the configured API key by asking Cal.com for
// the API-key owner's event types.
func (h *CalcomHandler) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
apiKey, err := h.settings.GetAPIKey(r.Context())
if err != nil {
writeJSON(w, map[string]any{"success": false, "error": "Failed to load settings"})
return
}
if apiKey == "" {
writeJSON(w, map[string]any{"success": false, "error": "API key not configured"})
return
}
client := NewCalcomClient(apiKey, h.rt)
ets, err := client.ListEventTypes(r.Context(), "")
if err != nil {
log.Printf("calcom: test connection: %v", err)
writeJSON(w, map[string]any{"success": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{
"success": true,
"event_types": ets,
})
}
// renderError renders an error message
func (h *CalcomHandler) renderError(w http.ResponseWriter, message, blockID string, canRetry bool) {
var buf bytes.Buffer
if err := CalcomError(message, blockID, canRetry).Render(context.Background(), &buf); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, writeErr := w.Write(htmlErrorFallback(message)); writeErr != nil {
log.Printf("failed to write Cal.com fallback error response: %v", writeErr)
}
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
log.Printf("failed to write Cal.com rendered error response: %v", err)
}
}
func htmlErrorFallback(message string) []byte {
return []byte(`<div class="p-4 bg-destructive/10 border border-destructive/20 rounded-lg"><p class="text-sm text-destructive">` + message + `</p></div>`)
}
// writeJSON sends a JSON response with the standard content type and best-effort logging.
func writeJSON(w http.ResponseWriter, payload any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Printf("calcom: encode JSON response: %v", err)
}
}
// maskSecret returns a masked version of a secret
func maskSecret(secret string) string {
if len(secret) <= 11 {
return "***"
}
return secret[:7] + "..." + secret[len(secret)-4:]
}