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>
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/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
|
|
}
|