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 } // 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() // 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) // blockConfig scans published block content for the server-authoritative // captcha requirement (CalcomBookingRequiresCaptcha over page_block_snapshots // in the public schema). No wasm-ABI capability exposes that today, and the // sandboxed plugin role cannot read it via SQL, so it is left nil: // bookingRequiresCaptcha then reports false and the baseline honeypot + // per-IP rate limit still apply. Restoring server-authoritative captcha // enforcement needs a future core capability that exposes published-block // content across the wasm ABI (tracked as a follow-up). h.blockConfig = nil // Public booking endpoints — reachable by anonymous visitors. r.Get("/slots", h.HandleGetSlots) r.Get("/form", h.HandleGetForm) r.Post("/book", h.HandleCreateBooking) r.Post("/cancel", h.HandleCancelBooking) r.Get("/reset", h.HandleReset) r.Get("/date-grid", h.HandleGetDateGrid) // Webhook receiver — HMAC-verified inside the handler, so no admin gate. wh := NewWebhookHandler(settings, slog.Default()) r.Post("/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("/settings", h.HandleGetSettings) admin.Post("/settings", h.HandleSaveSettings) admin.Post("/settings/rotate-webhook-secret", h.HandleRotateWebhookSecret) admin.Post("/test", h.HandleTestConnection) admin.Get("/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, apiKey, username, eventType string) string { client := NewCalcomClient(apiKey) 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 "Mon–Fri 8–6" 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, 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) 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) 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) 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) 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) 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) 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) 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) 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(`

` + message + `

`) } // 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:] }