package main import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "git.dev.alexdunmow.com/block/calcomblock/internal/helpers" ) // Notifier matches the existing CMS notifier callback signature // (handlers/webhook_ingestion.go:34). The wiring lives in // internal/server/server.go where the notification service is available. type Notifier func(ctx context.Context, severity, title, message, uniqueKey string) // notifier is a package-level callback set once at server startup via // SetNotifyFn. nil-safe: dispatch() checks before invoking. var notifier Notifier // SetNotifyFn wires the CMS notification service into the cal.com webhook // dispatcher. Called exactly once at server startup from server.go. func SetNotifyFn(fn Notifier) { notifier = fn } // WebhookHandler accepts Cal.com webhook events, verifies their HMAC signature // against the configured secret, and fans them out via the package-level // notifier. Mirrors the Stripe pattern of returning 200 on app-level errors so // the sender does not flood retries with junk. type WebhookHandler struct { settings *SettingsManager logger *slog.Logger } // NewWebhookHandler builds a webhook handler bound to the given settings // store. The logger is used for security-relevant events (invalid signatures, // missing configuration); body content is never logged. func NewWebhookHandler(settings *SettingsManager, logger *slog.Logger) *WebhookHandler { if logger == nil { logger = slog.Default() } return &WebhookHandler{settings: settings, logger: logger} } // Handle is the HTTP handler for POST /webhook. func (h *WebhookHandler) Handle(w http.ResponseWriter, r *http.Request) { ctx := r.Context() sourceIP := helpers.GetRealIP(r) body, err := io.ReadAll(io.LimitReader(r.Body, 10*1024*1024)) if err != nil { h.logger.Warn("calcom webhook: read body", "error", err, "ip", sourceIP) http.Error(w, "bad request", http.StatusBadRequest) return } secret, err := h.settings.GetWebhookSecret(ctx) if err != nil { h.logger.Warn("calcom webhook: load secret", "error", err, "ip", sourceIP) http.Error(w, "internal error", http.StatusInternalServerError) return } if secret == "" { h.logger.Warn("calcom webhook: not configured", "ip", sourceIP) http.Error(w, "webhook not configured", http.StatusServiceUnavailable) return } sig := r.Header.Get("X-Cal-Signature-256") if sig == "" || !verifyCalcomSignature(body, sig, secret) { h.logger.Warn("calcom webhook: invalid signature", "ip", sourceIP) http.Error(w, "invalid signature", http.StatusUnauthorized) return } var evt WebhookEvent if err := json.Unmarshal(body, &evt); err != nil { // Match the stripe pattern: ack invalid JSON so Cal.com doesn't keep retrying. h.logger.Warn("calcom webhook: bad json", "error", err) w.WriteHeader(http.StatusOK) return } // Run dispatch + last-event recording inside a recover so a panicking // notifier or settings store can't bubble a 500 to Cal.com and trigger a // retry storm. Cal.com retries 5xx responses; the Stripe pattern in the // rest of the CMS is to always ack 200 once the signature has verified. func() { defer func() { if rec := recover(); rec != nil { h.logger.Error("calcom webhook: panic in dispatch", "panic", rec) } }() h.dispatch(ctx, evt) if err := h.settings.UpdateLastEvent(ctx, evt.TriggerEvent, evt.CreatedAt); err != nil { h.logger.Warn("calcom webhook: update last event", "error", err) } }() w.WriteHeader(http.StatusOK) } // dispatch fans the parsed event out to the package notifier. Unknown trigger // events are silently ignored — Cal.com adds new event types over time and we // don't want noisy notifications for events we haven't taught the plugin yet. func (h *WebhookHandler) dispatch(ctx context.Context, evt WebhookEvent) { if notifier == nil { return } attendeeName := "" if len(evt.Payload.Attendees) > 0 { attendeeName = evt.Payload.Attendees[0].Name } eventTitle := evt.Payload.EventType.Title start := evt.Payload.StartTime uniqueKey := fmt.Sprintf("calcom:%s:%s", evt.TriggerEvent, evt.Payload.UID) var title, message string switch evt.TriggerEvent { case "BOOKING_CREATED": title = "New booking" message = fmt.Sprintf("%s booked %s at %s", attendeeName, eventTitle, start) case "BOOKING_RESCHEDULED": title = "Booking rescheduled" message = fmt.Sprintf("%s moved %s to %s", attendeeName, eventTitle, start) case "BOOKING_CANCELLED": title = "Booking cancelled" message = fmt.Sprintf("%s cancelled %s at %s", attendeeName, eventTitle, start) default: return } notifier(ctx, "info", title, message, uniqueKey) } // verifyCalcomSignature checks a Cal.com webhook signature. Accepts both the // canonical "sha256=" and bare "" formats to match the established // in-house pattern from handlers/webhook_ingestion.go:592. All comparisons use // constant-time hmac.Equal to avoid timing-leak side channels. func verifyCalcomSignature(payload []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(payload) expectedHex := hex.EncodeToString(mac.Sum(nil)) expectedSig := "sha256=" + expectedHex sig := strings.TrimSpace(signature) if hmac.Equal([]byte(expectedSig), []byte(sig)) { return true } if hmac.Equal([]byte(expectedHex), []byte(sig)) { return true } return false }