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:" // (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 }