Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
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
|
||||
|
||||
// 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(),
|
||||
MetricsPublic: getenvBool("METRICS_PUBLIC", 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).
|
||||
func (c Config) CookieSecure() bool {
|
||||
return c.SessionSecure || c.IsProduction()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if !c.SessionSecure {
|
||||
return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production")
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.WebOrigin)), "https://") {
|
||||
return fmt.Errorf("WEB_ORIGIN must be https in production")
|
||||
}
|
||||
if isLoopbackWebOriginHost(c.WebOrigin) {
|
||||
return fmt.Errorf("WEB_ORIGIN must not be localhost/loopback in production")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user