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>
161 lines
5.3 KiB
Go
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
|
|
}
|