calcomblock/webhook_handler.go
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

161 lines
5.3 KiB
Go

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=<hex>" and bare "<hex>" 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
}