calcomblock/settings_store.go
Alex Dunmow 324a2e6fe0 chore: migrate from block/core to block/pluginsdk (keep core for captcha)
Rewrite imports for plugin, blocks, templates, auth, settings, crypto, and
rbac packages from git.dev.alexdunmow.com/block/core/* to
git.dev.alexdunmow.com/block/pluginsdk/* across all Go files. The captcha
package stays on block/core, so the repo keeps both requires.

go.mod: add pluginsdk v0.1.0; bump core v0.20.2 -> v0.20.3 (satisfies the
check-safety core-version gate); transitive churn pgx v5.9.2 -> v5.10.0 and
x/mod v0.34.0 -> v0.37.0 from go mod tidy.

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

248 lines
8.4 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"strings"
"time"
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
"git.dev.alexdunmow.com/block/pluginsdk/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
}