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>
248 lines
8.4 KiB
Go
248 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.dev.alexdunmow.com/block/core/crypto"
|
|
"git.dev.alexdunmow.com/block/core/settings"
|
|
|
|
"git.dev.alexdunmow.com/block/calcomblock/internal/db"
|
|
"git.dev.alexdunmow.com/block/calcomblock/internal/helpers"
|
|
)
|
|
|
|
// SettingsManager persists Cal.com plugin settings through the SDK settings
|
|
// capabilities (a capabilityQuerier satisfying helpers.PluginSettingsQuerier in
|
|
// production; an in-memory fake in tests). Secrets are encrypted using the
|
|
// SDK-provided crypto.Crypto interface.
|
|
type SettingsManager struct {
|
|
crypto crypto.Crypto
|
|
queries helpers.PluginSettingsQuerier
|
|
}
|
|
|
|
// NewSettingsManager constructs a SettingsManager backed by the given crypto
|
|
// and querier. The querier must satisfy helpers.PluginSettingsQuerier.
|
|
func NewSettingsManager(c crypto.Crypto, q helpers.PluginSettingsQuerier) *SettingsManager {
|
|
return &SettingsManager{crypto: c, queries: q}
|
|
}
|
|
|
|
// GetAPIKey returns the decrypted Cal.com API key, or empty string if not configured.
|
|
func (m *SettingsManager) GetAPIKey(ctx context.Context) (string, error) {
|
|
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
enc := helpers.GetStringOr(s, "api_key_encrypted", "")
|
|
if enc == "" {
|
|
return "", nil
|
|
}
|
|
return m.crypto.DecryptSecret(enc)
|
|
}
|
|
|
|
// SetAPIKey encrypts and stores the Cal.com API key. Pass an empty string to clear.
|
|
func (m *SettingsManager) SetAPIKey(ctx context.Context, key string) error {
|
|
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key == "" {
|
|
delete(s, "api_key_encrypted")
|
|
} else {
|
|
enc, err := m.crypto.EncryptSecret(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s["api_key_encrypted"] = enc
|
|
}
|
|
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
|
|
}
|
|
|
|
// GetSettings returns the full settings blob. Secrets are NOT decrypted here —
|
|
// the caller is responsible for not exposing `api_key_encrypted` directly.
|
|
func (m *SettingsManager) GetSettings(ctx context.Context) (map[string]any, error) {
|
|
return helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
}
|
|
|
|
// GetUsername returns the Cal.com account username associated with the
|
|
// configured API key. Stored as plain text (not a secret); populated by
|
|
// HandleSaveSettings via CalcomClient.GetMe. Empty string when the API key
|
|
// has never been saved or the /me lookup has not yet succeeded.
|
|
func (m *SettingsManager) GetUsername(ctx context.Context) (string, error) {
|
|
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return helpers.GetStringOr(s, "username", ""), nil
|
|
}
|
|
|
|
// SetUsername persists the Cal.com account username. Pass an empty string to
|
|
// clear (e.g. when the API key is removed).
|
|
func (m *SettingsManager) SetUsername(ctx context.Context, username string) error {
|
|
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if username == "" {
|
|
delete(s, "username")
|
|
} else {
|
|
s["username"] = username
|
|
}
|
|
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
|
|
}
|
|
|
|
// GetSiteTimezone returns the site-wide IANA timezone configured under Site
|
|
// Settings → Localization, or "" when unset. Used as the fallback display
|
|
// timezone for booking times when the visitor's request carries none.
|
|
//
|
|
// Reads the "site" settings key through the querier, which resolves it via the
|
|
// SDK settings capability (settings.Settings.GetSiteSettings) host-side.
|
|
func (m *SettingsManager) GetSiteTimezone(ctx context.Context) string {
|
|
setting, err := m.queries.GetSetting(ctx, "site")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var site struct {
|
|
Localization struct {
|
|
Timezone string `json:"timezone"`
|
|
} `json:"localization"`
|
|
}
|
|
if err := json.Unmarshal(setting.Value, &site); err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(site.Localization.Timezone)
|
|
}
|
|
|
|
// resolveLocation resolves the IANA timezone used to window and display
|
|
// booking times. Resolution order: the visitor-supplied name (browser-detected,
|
|
// user-controlled input) when it parses, else the site-wide Localization
|
|
// timezone from site settings, else UTC. A literal "UTC" request is treated as
|
|
// unset: it is the widget's never-updated sentinel default (pages cached from
|
|
// before timezone detection, no-JS visitors, bots), so the site timezone is a
|
|
// strictly better guess for a human visitor.
|
|
//
|
|
// Returns the location plus its canonical name for forwarding to Cal.com.
|
|
// Lives on SettingsManager (not the handler) so the initial block render —
|
|
// which has no *http.Request — can resolve via the same ladder.
|
|
func (m *SettingsManager) resolveLocation(ctx context.Context, requested string) (*time.Location, string) {
|
|
for _, name := range []string{strings.TrimSpace(requested), m.GetSiteTimezone(ctx)} {
|
|
if name == "" || strings.EqualFold(name, "UTC") {
|
|
continue
|
|
}
|
|
if loc, err := time.LoadLocation(name); err == nil {
|
|
return loc, name
|
|
}
|
|
}
|
|
return time.UTC, "UTC"
|
|
}
|
|
|
|
// GetWebhookSecret returns the decrypted webhook HMAC secret, or empty if none
|
|
// has been provisioned yet.
|
|
func (m *SettingsManager) GetWebhookSecret(ctx context.Context) (string, error) {
|
|
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
enc := helpers.GetStringOr(s, "webhook_secret_encrypted", "")
|
|
if enc == "" {
|
|
return "", nil
|
|
}
|
|
return m.crypto.DecryptSecret(enc)
|
|
}
|
|
|
|
// SetWebhookSecret encrypts and persists the webhook HMAC secret. Pass an
|
|
// empty string to clear.
|
|
func (m *SettingsManager) SetWebhookSecret(ctx context.Context, secret string) error {
|
|
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if secret == "" {
|
|
delete(s, "webhook_secret_encrypted")
|
|
} else {
|
|
enc, err := m.crypto.EncryptSecret(secret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s["webhook_secret_encrypted"] = enc
|
|
}
|
|
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
|
|
}
|
|
|
|
// UpdateLastEvent records that the webhook handler received a Cal.com event
|
|
// at the given time. Surfaced to the admin settings panel via GetSettings.
|
|
func (m *SettingsManager) UpdateLastEvent(ctx context.Context, triggerEvent, createdAt string) error {
|
|
s, err := helpers.GetPluginSettings(ctx, m.queries, "calcomblock")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s["last_event_type"] = triggerEvent
|
|
s["last_event_at"] = createdAt
|
|
return helpers.SetPluginSettings(ctx, m.queries, "calcomblock", s)
|
|
}
|
|
|
|
// generateWebhookSecret returns a fresh 32-byte secret encoded as a 64-char
|
|
// hex string. Cryptographically random via crypto/rand.
|
|
func generateWebhookSecret() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// capabilityQuerier adapts the SDK settings capabilities to
|
|
// helpers.PluginSettingsQuerier. Under the wasm ABI a plugin's Postgres role is
|
|
// sandboxed to its OWN schema, so the CMS `settings` table (public schema) is
|
|
// unreachable by direct SQL — settings persistence MUST go through the
|
|
// host-side capability interfaces (settings.Settings / settings.Updater), which
|
|
// perform the DB work on the host's behalf.
|
|
//
|
|
// It recognises the two keys the settings helpers request: "plugin:<name>"
|
|
// (round-tripped through Get/UpdatePluginSettings) and "site" (read-only via
|
|
// GetSiteSettings). Values are JSON, matching the shape the helpers expect from
|
|
// the old `settings.value` bytea column.
|
|
type capabilityQuerier struct {
|
|
settings settings.Settings
|
|
updater settings.Updater
|
|
}
|
|
|
|
func (q *capabilityQuerier) GetSetting(ctx context.Context, key string) (db.Setting, error) {
|
|
var (
|
|
m map[string]any
|
|
err error
|
|
)
|
|
if key == "site" {
|
|
m, err = q.settings.GetSiteSettings(ctx)
|
|
} else {
|
|
m, err = q.settings.GetPluginSettings(ctx, strings.TrimPrefix(key, "plugin:"))
|
|
}
|
|
if err != nil {
|
|
return db.Setting{}, err
|
|
}
|
|
value, err := json.Marshal(m)
|
|
if err != nil {
|
|
return db.Setting{}, err
|
|
}
|
|
return db.Setting{Key: key, Value: value}, nil
|
|
}
|
|
|
|
func (q *capabilityQuerier) UpsertSetting(ctx context.Context, arg db.UpsertSettingParams) (db.Setting, error) {
|
|
var m map[string]any
|
|
if len(arg.Value) > 0 {
|
|
if err := json.Unmarshal(arg.Value, &m); err != nil {
|
|
return db.Setting{}, err
|
|
}
|
|
}
|
|
if m == nil {
|
|
m = map[string]any{}
|
|
}
|
|
if err := q.updater.UpdatePluginSettings(ctx, strings.TrimPrefix(arg.Key, "plugin:"), m); err != nil {
|
|
return db.Setting{}, err
|
|
}
|
|
return db.Setting(arg), nil
|
|
}
|