// Package helpers vendors the small set of utility functions the calcomblock // plugin previously imported from the CMS-internal package // git.dev.alexdunmow.com/block/cms/internal/helpers. A standalone wasm plugin // may only import git.dev.alexdunmow.com/block/core/... — never the CMS // internals — so these pure, self-contained helpers are copied here verbatim // (provenance: cms/backend/internal/helpers/{http,cleanup,maps,plugin_settings}.go). // // Re-copy on upstream changes; nothing here touches the CMS DB or capabilities. package helpers import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "strings" "time" "git.dev.alexdunmow.com/block/calcomblock/internal/db" ) // GetRealIP extracts the client IP from proxy headers, trusting the rightmost // X-Forwarded-For entry (added by our trusted reverse proxy), then X-Real-IP, // then CF-Connecting-IP, then RemoteAddr. func GetRealIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { parts := strings.Split(xff, ",") for i := len(parts) - 1; i >= 0; i-- { ip := strings.TrimSpace(parts[i]) if ip != "" { return ip } } } if xri := r.Header.Get("X-Real-IP"); xri != "" { return xri } if cfip := r.Header.Get("CF-Connecting-IP"); cfip != "" { return cfip } ip := r.RemoteAddr if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 { if !strings.Contains(ip, "[") || strings.Contains(ip[colonIdx:], "]") { ip = ip[:colonIdx] } } ip = strings.TrimPrefix(ip, "[") ip = strings.TrimSuffix(ip, "]") return ip } // CleanupFunc is a function that performs cleanup work. type CleanupFunc func() // StartCleanupLoop starts a background goroutine that calls cleanupFn at the // given interval until the context is cancelled. func StartCleanupLoop(ctx context.Context, interval time.Duration, cleanupFn CleanupFunc) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: cleanupFn() } } }() } // GetStringOr returns a string value from a map, or defaultVal if not // found/wrong type. func GetStringOr(m map[string]any, key, defaultVal string) string { if v, ok := m[key].(string); ok { return v } return defaultVal } // MaskSecret returns a masked version of a secret for display, showing the // first 7 and last 4 characters. Example: "cal_live_1234567890abcdef" -> // "cal_liv...cdef". func MaskSecret(secret string) string { if len(secret) <= 11 { return "***" } return secret[:7] + "..." + secret[len(secret)-4:] } // PluginSettingsQuerier is the interface required for plugin settings storage. // The production implementation is capability-backed (see settings_store.go); // tests inject an in-memory fake. type PluginSettingsQuerier interface { GetSetting(ctx context.Context, key string) (db.Setting, error) UpsertSetting(ctx context.Context, arg db.UpsertSettingParams) (db.Setting, error) } // GetPluginSettings retrieves settings for a plugin, stored under key // "plugin:{pluginName}". Returns an empty map when none exist. func GetPluginSettings(ctx context.Context, q PluginSettingsQuerier, pluginName string) (map[string]any, error) { key := "plugin:" + pluginName setting, err := q.GetSetting(ctx, key) if err != nil { return make(map[string]any), nil } var result map[string]any if err := json.Unmarshal(setting.Value, &result); err != nil { return nil, fmt.Errorf("failed to parse plugin settings: %w", err) } if result == nil { result = make(map[string]any) } return result, nil } // SetPluginSettings persists settings for a plugin under key // "plugin:{pluginName}". func SetPluginSettings(ctx context.Context, q PluginSettingsQuerier, pluginName string, settings map[string]any) error { key := "plugin:" + pluginName value, err := json.Marshal(settings) if err != nil { return fmt.Errorf("failed to serialize plugin settings: %w", err) } _, err = q.UpsertSetting(ctx, db.UpsertSettingParams{Key: key, Value: value}) return err } // PluginCrypto provides AES-256-GCM encryption/decryption for plugin secrets. // The production path uses the SDK-provided crypto.Crypto; this is retained for // the plugin's unit tests, which need a concrete implementation. type PluginCrypto struct { encryptionKey []byte } // NewPluginCrypto creates a PluginCrypto whose key is padded/truncated to 32 // bytes for AES-256. func NewPluginCrypto(secretKey string) *PluginCrypto { key := make([]byte, 32) copy(key, []byte(secretKey)) return &PluginCrypto{encryptionKey: key} } // EncryptSecret encrypts a value with AES-256-GCM, returning hex. func (c *PluginCrypto) EncryptSecret(plaintext string) (string, error) { if plaintext == "" { return "", nil } block, err := aes.NewCipher(c.encryptionKey) if err != nil { return "", fmt.Errorf("create cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("create gcm: %w", err) } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", fmt.Errorf("generate nonce: %w", err) } ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) return hex.EncodeToString(ciphertext), nil } // DecryptSecret decrypts a hex AES-256-GCM ciphertext, falling back to the // input verbatim for legacy unencrypted values. func (c *PluginCrypto) DecryptSecret(cipherHex string) (string, error) { if cipherHex == "" { return "", nil } ciphertext, err := hex.DecodeString(cipherHex) if err != nil { return cipherHex, nil } block, err := aes.NewCipher(c.encryptionKey) if err != nil { return "", fmt.Errorf("create cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("create gcm: %w", err) } if len(ciphertext) < gcm.NonceSize() { return cipherHex, nil } nonce := ciphertext[:gcm.NonceSize()] ciphertext = ciphertext[gcm.NonceSize():] plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return cipherHex, nil } return string(plaintext), nil }