Files
descrybe/apps/api/internal/config/config.go
T

748 lines
27 KiB
Go
Raw Normal View History

package config
import (
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
// AppEnv is development|staging|production. Production fails closed on insecure knobs.
AppEnv string
DatabaseURL string
HTTPAddr string
WebOrigin string
// TrustedProxies lists reverse-proxy CIDRs/IPs allowed to set client IP
// headers (X-Forwarded-For, X-Real-IP, True-Client-IP). Empty (default)
// ignores those headers — safe for local and direct exposure.
TrustedProxies []string
// RateLimitReplicas divides HTTP middleware caps in httpapi/ratelimit.go (ceil)
// so aggregate under even load approximates documented RPM. Default 1.
// Does not affect login lockout, StartLimiter, AIRateLimiter, or email limiters.
// Not a shared store — multi-replica hard global caps still need edge/WAF cutover.
// Env: RATE_LIMIT_REPLICAS.
RateLimitReplicas int
// RateLimitMultiReplica is an ops acknowledgment that multiple API replicas run
// without a shared limiter store. Boots with a warning when true or replicas > 1.
// Env: RATE_LIMIT_MULTI_REPLICA.
RateLimitMultiReplica bool
// RateLimitBackend is the effective limiter store (always "memory" today).
RateLimitBackend string
// RateLimitBackendRequested is the raw RATE_LIMIT_BACKEND value when unsupported
// (e.g. redis/postgres) so boot can warn that memory was forced.
RateLimitBackendRequested string
SessionCookieName string
SessionSecure bool
CSRFCookieName string
PublicAPIURL string
MigrateMySQLDSN string
// MaintenanceMode rejects all non-health traffic with 503 (cutover freeze / emergency).
MaintenanceMode bool
// ReadOnlyMode rejects mutating methods (POST/PUT/PATCH/DELETE) with 503; GETs still work.
ReadOnlyMode bool
// HypercareMode shows the tenant “report missing/wrong data” CTA (P1-17); clear to end the window.
HypercareMode bool
SessionIdleHours int
LowCreditsThreshold int
SMTPEnabled bool
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
TokenSigningSecret string
// AI processing (worker). Prefer admin platform settings (DB); env is optional bootstrap fallback.
OpenAIAPIKey string
OpenAIBaseURL string
OpenAIModel string
// Optional embeddings bootstrap for admin AI role "vectorization".
// Empty key/base fall back to OpenAIAPIKey / OpenAIBaseURL at resolve time.
OpenAIEmbeddingAPIKey string
OpenAIEmbeddingBaseURL string
OpenAIEmbeddingModel string
ProcessingRPM int
ProcessingMaxRetries int
ProcessingBatchSize int
ProcessingPollInterval time.Duration // worker ClaimNext/Fill idle tick
PineconeAPIKey string
PineconeHost string
PineconeNamespace string
UploadDir string
// CredentialsEncryptionKey encrypts WooCommerce consumer secrets at rest.
// Prefer APP_ENCRYPTION_KEY, else CREDENTIALS_ENCRYPTION_KEY; falls back to TokenSigningSecret / DATABASE_URL.
CredentialsEncryptionKey string
// AppEncryptionKey encrypts tenant email provider secrets (Resend/SMTP) at rest.
// Prefer APP_ENCRYPTION_KEY; falls back to CredentialsEncryptionKey then TokenSigningSecret.
AppEncryptionKey string
// Marketing email send rate limits (per company, in-process).
EmailSendRPM int
EmailSendRPH int
// EmailDryRun forces transactional + campaign/provider sends to log-only (also forced for Free plan).
// Default true when EMAIL_DRY_RUN unset (safe). Preferred source of truth is admin
// platform settings (smtp.email_dry_run / mail.email_dry_run); env is bootstrap fallback.
EmailDryRun bool
// EmailDryRunSet is true when EMAIL_DRY_RUN was explicitly present in the process env.
EmailDryRunSet bool
// ResendAPIKey is an optional platform-level Resend key used when a company has no key.
// Prefer admin platform settings; env is bootstrap fallback only.
ResendAPIKey string
// EPREL public energy-label enrichment during product processing.
EPRELEnabled bool
EPRELBaseURL string
EPRELTimeout time.Duration
EPRELFicheLanguage string
EPRELAPIKey string // optional; never log
// Stripe billing (Checkout + Customer Portal + webhooks). Empty secret → mock mode.
StripeSecretKey string
StripeWebhookSecret string
StripeMock bool
StripePriceIDs map[string]string // "starter:monthly" → price_…
// MetricsPublic exposes GET /metrics beyond loopback in production (METRICS_PUBLIC=1).
// Non-production always allows scrapes. Production without this flag: loopback only.
MetricsPublic bool
2026-08-16 17:38:15 +02:00
// AllowInsecureLocalProduction permits loopback WEB_ORIGIN (http or https) when
// APP_ENV=production|prod. Env: ALLOW_INSECURE_LOCAL_PRODUCTION=1.
// Public (non-loopback) origins still require https. Use only for local systemd /
// npm run dev mislabeled as production — never for a public deploy.
AllowInsecureLocalProduction bool
// Postgres pgx pool (api + worker). Defaults preserve historical NewPool hardcodes
// and add idle recycle + statement_timeout for multi-tenant churn.
// See docs/ops-runtime.md § Postgres pgx pool and db.PoolOptions comments.
DBMaxConns int
DBMinConns int
DBMaxConnLifetime time.Duration
DBMaxConnLifetimeJitter time.Duration
DBMaxConnIdleTime time.Duration
DBHealthCheckPeriod time.Duration
DBStatementTimeout time.Duration
}
func Load() (Config, error) {
// Monorepo root .env is the single local source of truth (see loadDotEnv).
loadDotEnv()
appEnv := getenv("APP_ENV", "development")
cfg := Config{
AppEnv: appEnv,
DatabaseURL: getenv("DATABASE_URL", "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"),
HTTPAddr: getenv("HTTP_ADDR", ":28471"),
WebOrigin: getenv("WEB_ORIGIN", "http://localhost:28472"),
TrustedProxies: parseCSVList(os.Getenv("TRUSTED_PROXIES")),
RateLimitReplicas: getenvInt("RATE_LIMIT_REPLICAS", 1),
RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false),
RateLimitBackend: "memory",
SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"),
// Default Secure=true when APP_ENV is production|prod so cookies are HTTPS-only
// even if SESSION_SECURE is unset; explicit false still fails closed in validate.
SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)),
CSRFCookieName: getenv("CSRF_COOKIE_NAME", "descrybe_csrf"),
PublicAPIURL: getenv("PUBLIC_API_URL", "http://localhost:28471"),
MigrateMySQLDSN: os.Getenv("MIGRATE_MYSQL_DSN"),
MaintenanceMode: getenvBool("MAINTENANCE_MODE", false),
ReadOnlyMode: getenvBool("READ_ONLY_MODE", false),
HypercareMode: getenvBool("HYPERCARE_MODE", false),
SessionIdleHours: getenvInt("SESSION_IDLE_HOURS", 24),
LowCreditsThreshold: getenvInt("LOW_CREDITS_THRESHOLD", 100),
SMTPEnabled: getenvBool("SMTP_ENABLED", false),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: getenv("SMTP_PORT", "587"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
SMTPFrom: getenv("SMTP_FROM", "noreply@localhost"),
TokenSigningSecret: os.Getenv("TOKEN_SIGNING_SECRET"),
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
OpenAIBaseURL: getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
OpenAIModel: getenv("OPENAI_MODEL", "gpt-4o-mini"),
OpenAIEmbeddingAPIKey: os.Getenv("OPENAI_EMBEDDING_API_KEY"),
OpenAIEmbeddingBaseURL: os.Getenv("OPENAI_EMBEDDING_BASE_URL"),
OpenAIEmbeddingModel: getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
ProcessingRPM: getenvInt("PROCESSING_RPM", 60),
ProcessingMaxRetries: getenvInt("PROCESSING_MAX_RETRIES", 3),
ProcessingBatchSize: getenvInt("PROCESSING_BATCH_SIZE", 100),
ProcessingPollInterval: getenvDuration("PROCESSING_POLL_INTERVAL", 250*time.Millisecond),
PineconeAPIKey: os.Getenv("PINECONE_API_KEY"),
PineconeHost: os.Getenv("PINECONE_HOST"),
PineconeNamespace: getenv("PINECONE_NAMESPACE", ""),
UploadDir: getenv("UPLOAD_DIR", "data/uploads"),
CredentialsEncryptionKey: firstEnv("APP_ENCRYPTION_KEY", "CREDENTIALS_ENCRYPTION_KEY"),
AppEncryptionKey: firstEnv("APP_ENCRYPTION_KEY", "CREDENTIALS_ENCRYPTION_KEY"),
EmailDryRun: getenvBool("EMAIL_DRY_RUN", true),
EmailDryRunSet: strings.TrimSpace(os.Getenv("EMAIL_DRY_RUN")) != "",
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailSendRPM: getenvInt("EMAIL_SEND_RPM", 30),
EmailSendRPH: getenvInt("EMAIL_SEND_RPH", 500),
EPRELEnabled: getenvBool("EPREL_ENABLED", true),
EPRELBaseURL: getenv("EPREL_BASE_URL", "https://eprel.ec.europa.eu/api"),
EPRELTimeout: getenvDuration("EPREL_TIMEOUT", 10*time.Second),
EPRELFicheLanguage: getenv("EPREL_FICHE_LANGUAGE", "EN"),
EPRELAPIKey: os.Getenv("EPREL_API_KEY"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripeMock: getenvBool("STRIPE_MOCK", false),
StripePriceIDs: loadStripePriceIDs(),
2026-08-16 17:38:15 +02:00
MetricsPublic: getenvBool("METRICS_PUBLIC", false),
AllowInsecureLocalProduction: getenvBool("ALLOW_INSECURE_LOCAL_PRODUCTION", false),
DBMaxConns: getenvInt("DB_MAX_CONNS", 20),
DBMinConns: getenvInt("DB_MIN_CONNS", 2),
DBMaxConnLifetime: getenvDuration("DB_MAX_CONN_LIFETIME", time.Hour),
DBMaxConnLifetimeJitter: getenvDurationAllowZero("DB_MAX_CONN_LIFETIME_JITTER", 6*time.Minute),
DBMaxConnIdleTime: getenvDuration("DB_MAX_CONN_IDLE_TIME", 5*time.Minute),
DBHealthCheckPeriod: getenvDuration("DB_HEALTH_CHECK_PERIOD", time.Minute),
DBStatementTimeout: getenvDurationAllowZero("DB_STATEMENT_TIMEOUT", 30*time.Second),
}
if strings.TrimSpace(cfg.DatabaseURL) == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required")
}
if cfg.RateLimitReplicas < 1 {
cfg.RateLimitReplicas = 1
}
if cfg.RateLimitReplicas > 128 {
cfg.RateLimitReplicas = 128
}
if requested := strings.ToLower(strings.TrimSpace(os.Getenv("RATE_LIMIT_BACKEND"))); requested != "" && requested != "memory" {
cfg.RateLimitBackendRequested = requested
}
cfg.RateLimitBackend = "memory"
if cfg.EPRELEnabled {
if err := validatePublicHTTPBaseURL(cfg.EPRELBaseURL, "EPREL_BASE_URL"); err != nil {
return Config{}, err
}
}
if err := cfg.validate(); err != nil {
return Config{}, err
}
cfg.WebOrigin = normalizeWebOrigin(cfg.WebOrigin)
return cfg, nil
}
// IsProduction reports whether APP_ENV is production (or prod).
func (c Config) IsProduction() bool {
return isProductionEnvValue(c.AppEnv)
}
// CookieSecure is true when session/CSRF cookies must carry the Secure flag.
// Prefer SessionSecure; also force Secure when APP_ENV is production (defense in depth).
2026-08-16 17:38:15 +02:00
// Exception: insecure local production escape with http loopback WEB_ORIGIN follows SessionSecure
// so cookies work on http://localhost during mislabeled local deploys.
func (c Config) CookieSecure() bool {
2026-08-16 17:38:15 +02:00
if c.InsecureLocalProductionActive() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.WebOrigin)), "http://") {
return c.SessionSecure
}
return c.SessionSecure || c.IsProduction()
}
2026-08-16 17:38:15 +02:00
// InsecureLocalProductionActive reports APP_ENV=production|prod with the explicit
// loopback escape (ALLOW_INSECURE_LOCAL_PRODUCTION) and a loopback WEB_ORIGIN.
func (c Config) InsecureLocalProductionActive() bool {
return c.IsProduction() && c.AllowInsecureLocalProduction && isLoopbackWebOriginHost(c.WebOrigin)
}
// SessionCookieParentDomain derives the session cookie Domain attribute from
// WEB_ORIGIN and PUBLIC_API_URL — no extra env needed. When web and API run on
// sibling hosts of one parent domain (descrybe.io + api.descrybe.io), the
// shared parent is returned so the browser also sends the session cookie to
// the web host; SvelteKit SSR gates (/admin) forward it to /api/auth/me and
// would otherwise always see 401. Same hostname (localhost dev, single-host
// deploys), IPs, or unrelated hosts → "" (host-only cookie, old behavior).
func (c Config) SessionCookieParentDomain() string {
web := hostnameOfURL(c.WebOrigin)
api := hostnameOfURL(c.PublicAPIURL)
if web == "" || api == "" || web == api {
return ""
}
if net.ParseIP(web) != nil || net.ParseIP(api) != nil ||
!strings.Contains(web, ".") || !strings.Contains(api, ".") {
return ""
}
// Direct parent/child: one host is the other's registrable parent.
if strings.HasSuffix(api, "."+web) {
return web
}
if strings.HasSuffix(web, "."+api) {
return api
}
// Sibling subdomains (app.x.y + api.x.y): share the deepest common suffix,
// but only when it has at least two labels (never a bare TLD).
if suffix := commonDotSuffix(web, api); strings.Count(suffix, ".") >= 1 {
return suffix
}
return ""
}
func hostnameOfURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
return strings.ToLower(u.Hostname())
}
// commonDotSuffix returns the longest label-aligned common suffix of two hostnames
// ("app.descrybe.io", "api.descrybe.io" → "descrybe.io"); "" when nothing matches.
func commonDotSuffix(a, b string) string {
la := strings.Split(a, ".")
lb := strings.Split(b, ".")
n := 0
for n < len(la) && n < len(lb) {
if la[len(la)-1-n] != lb[len(lb)-1-n] {
break
}
n++
}
// Never return one of the full hostnames itself (that is the parent/child case).
if n == 0 || n == len(la) || n == len(lb) {
return ""
}
return strings.Join(la[len(la)-n:], ".")
}
// ShouldWarnRateLimits reports whether operators opted into multi-replica rate-limit
// awareness or requested an unsupported shared backend.
func (c Config) ShouldWarnRateLimits() bool {
return c.RateLimitMultiReplica || c.RateLimitReplicas > 1 || c.RateLimitBackendRequested != ""
}
// RateLimitWarningMessage is a stable ops-facing explanation for in-process limits.
func (c Config) RateLimitWarningMessage() string {
msg := "HTTP rate limits are in-process only (no Redis/shared store); multi-replica hard caps need edge/WAF; optional RATE_LIMIT_REPLICAS divides HTTP middleware caps only (not lockout/StartLimiter/AI/email)"
if c.RateLimitBackendRequested != "" {
msg += "; RATE_LIMIT_BACKEND=" + c.RateLimitBackendRequested + " is not implemented — using memory"
}
return msg
}
// IsProductionEnv reports whether the live APP_ENV is production or prod.
// Used by credential crypto helpers that do not hold a Config value.
func IsProductionEnv() bool {
return isProductionEnvValue(os.Getenv("APP_ENV"))
}
func isProductionEnvValue(e string) bool {
e = strings.ToLower(strings.TrimSpace(e))
return e == "production" || e == "prod"
}
func (c Config) validate() error {
if err := validateWebOrigin(c.WebOrigin); err != nil {
return err
}
if _, err := ParseTrustedProxyNets(c.TrustedProxies); err != nil {
return err
}
if err := c.validateDBPool(); err != nil {
return err
}
if c.ProcessingPollInterval <= 0 {
return fmt.Errorf("PROCESSING_POLL_INTERVAL must be > 0")
}
if err := c.validateSMTPConfig(); err != nil {
return err
}
if !c.IsProduction() {
return nil
}
2026-08-16 17:38:15 +02:00
if err := c.validateProductionWebOrigin(); err != nil {
return err
}
if strings.TrimSpace(c.AppEncryptionKey) == "" {
return fmt.Errorf("APP_ENCRYPTION_KEY is required in production")
}
if strings.TrimSpace(c.TokenSigningSecret) == "" {
return fmt.Errorf("TOKEN_SIGNING_SECRET is required in production")
}
if c.StripeMock {
return fmt.Errorf("STRIPE_MOCK must be false in production")
}
// Stripe secret/webhook keys may live in admin platform_settings; boot does not
// require env STRIPE_* (checkout/webhooks fail closed until configured).
if err := c.validateProductionMail(); err != nil {
return err
}
return nil
}
2026-08-16 17:38:15 +02:00
// validateProductionWebOrigin fails closed for public production: https + non-loopback
// WEB_ORIGIN and SESSION_SECURE. Loopback http(s) is allowed only with the explicit
// ALLOW_INSECURE_LOCAL_PRODUCTION escape (local systemd / npm run dev mislabeled as prod).
func (c Config) validateProductionWebOrigin() error {
origin := strings.TrimSpace(c.WebOrigin)
https := strings.HasPrefix(strings.ToLower(origin), "https://")
loopback := isLoopbackWebOriginHost(origin)
localEscape := c.AllowInsecureLocalProduction && loopback
if localEscape {
// SESSION_SECURE is optional for http://localhost escape so cookies work locally.
if https && !c.SessionSecure {
return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production and WEB_ORIGIN is https")
}
return nil
}
// Prefer WEB_ORIGIN diagnostics for the common local-mislabeled-production crash loop
// (http://localhost + APP_ENV=production) before SESSION_SECURE.
if !https {
if loopback {
return fmt.Errorf("WEB_ORIGIN must be https in production (got %q). Public deploy: set WEB_ORIGIN=https://your.domain. Local systemd/npm run dev: set APP_ENV=development, or ALLOW_INSECURE_LOCAL_PRODUCTION=1 with loopback WEB_ORIGIN only — otherwise api/worker exit and leave processing_jobs status=running forever", origin)
}
return fmt.Errorf("WEB_ORIGIN must be https in production (got %q); set WEB_ORIGIN=https://your.public.domain", origin)
}
if loopback {
return fmt.Errorf("WEB_ORIGIN must not be localhost/loopback in production (got %q). Public deploy: use your https domain. Local only: APP_ENV=development or ALLOW_INSECURE_LOCAL_PRODUCTION=1", origin)
}
if !c.SessionSecure {
return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production")
}
return nil
}
// validateSMTPConfig no longer fails closed at boot: SMTP credentials live in
// admin platform settings (with optional env fallback). Incomplete SMTP_ENABLED
// env is ignored until admin configures delivery.
func (c Config) validateSMTPConfig() error {
return nil
}
// validateProductionMail no longer requires RESEND_API_KEY / SMTP_HOST in env.
// Live delivery is gated at send time via platform settings + tenant providers.
// Still rejects localhost From when SMTP_ENABLED env is true (misconfig hint).
func (c Config) validateProductionMail() error {
if c.SMTPEnabled {
from := strings.ToLower(strings.TrimSpace(c.SMTPFrom))
if from != "" && strings.HasSuffix(from, "@localhost") {
return fmt.Errorf("SMTP_FROM must not be a localhost address in production when SMTP_ENABLED=true")
}
}
return nil
}
func (c Config) validateDBPool() error {
if c.DBMaxConns < 1 {
return fmt.Errorf("DB_MAX_CONNS must be >= 1")
}
if c.DBMinConns < 0 {
return fmt.Errorf("DB_MIN_CONNS must be >= 0")
}
if c.DBMinConns > c.DBMaxConns {
return fmt.Errorf("DB_MIN_CONNS must be <= DB_MAX_CONNS")
}
if c.DBMaxConnLifetime <= 0 {
return fmt.Errorf("DB_MAX_CONN_LIFETIME must be > 0")
}
if c.DBMaxConnLifetimeJitter < 0 {
return fmt.Errorf("DB_MAX_CONN_LIFETIME_JITTER must be >= 0")
}
if c.DBMaxConnIdleTime <= 0 {
return fmt.Errorf("DB_MAX_CONN_IDLE_TIME must be > 0")
}
if c.DBHealthCheckPeriod <= 0 {
return fmt.Errorf("DB_HEALTH_CHECK_PERIOD must be > 0")
}
// Statement timeout may be 0 to disable the GUC.
if c.DBStatementTimeout < 0 {
return fmt.Errorf("DB_STATEMENT_TIMEOUT must be >= 0")
}
return nil
}
func validateWebOrigin(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("WEB_ORIGIN is required")
}
if raw == "*" {
return fmt.Errorf("WEB_ORIGIN must not be * (credentials CORS)")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("WEB_ORIGIN is invalid")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("WEB_ORIGIN must be an absolute http(s) origin")
}
if u.Host == "" {
return fmt.Errorf("WEB_ORIGIN host is required")
}
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
return fmt.Errorf("WEB_ORIGIN must be an origin only (no path)")
}
return nil
}
// normalizeWebOrigin returns scheme://host (no trailing slash/path) for CORS exact match.
func normalizeWebOrigin(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme == "" || u.Host == "" {
return strings.TrimSpace(raw)
}
return strings.ToLower(u.Scheme) + "://" + u.Host
}
// CORSAllowedOrigins returns WEB_ORIGIN plus the localhost↔127.0.0.1 twin when the
// configured origin is already loopback. Browsers treat those hostnames as distinct
// origins; without the twin, Vite opened via the other hostname fails credentialed CORS.
// Production rejects loopback WEB_ORIGIN, so this never widens a real deploy origin.
func CORSAllowedOrigins(webOrigin string) []string {
origin := normalizeWebOrigin(webOrigin)
if origin == "" {
return nil
}
out := []string{origin}
if twin := loopbackOriginTwin(origin); twin != "" && twin != origin {
out = append(out, twin)
}
return out
}
func loopbackOriginTwin(origin string) string {
u, err := url.Parse(strings.TrimSpace(origin))
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
host := strings.ToLower(u.Hostname())
var twinHost string
switch host {
case "localhost":
twinHost = "127.0.0.1"
case "127.0.0.1":
twinHost = "localhost"
default:
return ""
}
scheme := strings.ToLower(u.Scheme)
if port := u.Port(); port != "" {
return scheme + "://" + twinHost + ":" + port
}
return scheme + "://" + twinHost
}
func isLoopbackWebOriginHost(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
return host == "localhost" || host == "127.0.0.1" || host == "::1" || strings.HasSuffix(host, ".localhost")
}
// ParseTrustedProxyNets parses TRUSTED_PROXIES entries (CIDR or single IP).
func ParseTrustedProxyNets(entries []string) ([]*net.IPNet, error) {
out := make([]*net.IPNet, 0, len(entries))
for _, raw := range entries {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if strings.Contains(raw, "/") {
_, n, err := net.ParseCIDR(raw)
if err != nil {
return nil, fmt.Errorf("TRUSTED_PROXIES invalid CIDR %q", raw)
}
out = append(out, n)
continue
}
ip := net.ParseIP(raw)
if ip == nil {
return nil, fmt.Errorf("TRUSTED_PROXIES invalid IP %q", raw)
}
if v4 := ip.To4(); v4 != nil {
out = append(out, &net.IPNet{IP: v4, Mask: net.CIDRMask(32, 32)})
continue
}
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)})
}
return out, nil
}
func parseCSVList(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
func validatePublicHTTPBaseURL(raw, name string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("%s is required when enabled", name)
}
// Lazy import avoided — parse manually for scheme/host only.
lower := strings.ToLower(raw)
if !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "http://") {
return fmt.Errorf("%s must be http(s)", name)
}
without := raw
if i := strings.Index(without, "://"); i >= 0 {
without = without[i+3:]
}
hostport := without
if i := strings.IndexAny(hostport, "/?#"); i >= 0 {
hostport = hostport[:i]
}
host := hostport
if i := strings.LastIndex(hostport, ":"); i >= 0 {
// strip port; handle IPv6 [::1]:port lightly by rejecting brackets for now
if !strings.HasPrefix(hostport, "[") {
host = hostport[:i]
}
}
host = strings.ToLower(strings.TrimSpace(host))
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
return fmt.Errorf("%s host is not allowed (SSRF)", name)
}
// Literal private IPs only (hostname DNS rebinding is operator-controlled for this official API URL).
if isLiteralPrivateHost(host) {
return fmt.Errorf("%s must not point at a private IP", name)
}
return nil
}
func isLiteralPrivateHost(host string) bool {
// Minimal check without importing net into every path — cover common private literals.
if host == "127.0.0.1" || host == "0.0.0.0" || host == "::1" {
return true
}
if strings.HasPrefix(host, "10.") || strings.HasPrefix(host, "192.168.") || strings.HasPrefix(host, "169.254.") {
return true
}
if strings.HasPrefix(host, "172.") {
parts := strings.Split(host, ".")
if len(parts) >= 2 {
var n int
if _, err := fmt.Sscanf(parts[1], "%d", &n); err == nil && n >= 16 && n <= 31 {
return true
}
}
}
return false
}
func loadStripePriceIDs() map[string]string {
out := map[string]string{}
pairs := []struct {
key string
env string
}{
{"starter:monthly", "STRIPE_PRICE_STARTER_MONTHLY"},
{"starter:yearly", "STRIPE_PRICE_STARTER_YEARLY"},
{"plus:monthly", "STRIPE_PRICE_PLUS_MONTHLY"},
{"plus:yearly", "STRIPE_PRICE_PLUS_YEARLY"},
{"growth:monthly", "STRIPE_PRICE_GROWTH_MONTHLY"},
{"growth:yearly", "STRIPE_PRICE_GROWTH_YEARLY"},
{"business:monthly", "STRIPE_PRICE_BUSINESS_MONTHLY"},
{"business:yearly", "STRIPE_PRICE_BUSINESS_YEARLY"},
{"scale:monthly", "STRIPE_PRICE_SCALE_MONTHLY"},
{"scale:yearly", "STRIPE_PRICE_SCALE_YEARLY"},
// Credit packs — keep IDs aligned with billing.DefaultCreditPacks.
{"pack:tiny", "STRIPE_PRICE_PACK_TINY"},
{"pack:small", "STRIPE_PRICE_PACK_SMALL"},
{"pack:medium", "STRIPE_PRICE_PACK_MEDIUM"},
{"pack:large", "STRIPE_PRICE_PACK_LARGE"},
{"pack:xl", "STRIPE_PRICE_PACK_XL"},
{"pack:xxl", "STRIPE_PRICE_PACK_XXL"},
{"pack:mega", "STRIPE_PRICE_PACK_MEGA"},
}
for _, p := range pairs {
if v := strings.TrimSpace(os.Getenv(p.env)); v != "" {
out[p.key] = v
}
}
return out
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// firstEnv returns the first non-empty process env among keys (no fallback default).
func firstEnv(keys ...string) string {
for _, key := range keys {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
}
return ""
}
func getenvBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func getenvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
// getenvDuration accepts Go durations ("10s", "500ms") or integer seconds ("10").
func getenvDuration(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
return fallback
}
// getenvDurationAllowZero is like getenvDuration but accepts 0 (e.g. disable statement_timeout).
func getenvDurationAllowZero(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return time.Duration(n) * time.Second
}
return fallback
}