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>
1157 lines
43 KiB
Go
1157 lines
43 KiB
Go
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 "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, 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:]
|
||
}
|