package config import ( "fmt" "net/url" "os" "strconv" "strings" "time" ) type Config struct { AppPort string AppEnv string SAMLCertFile string SAMLKeyFile string SAMLMetadataFile string SAMLRootURL string RedisAddr string RedisPassword string RedisDB int FrontendURL string CookieDomain string CookieSecure bool SessionTTLHour int } type SAMLConfig struct { CertFile string KeyFile string MetadataFile string RootURL string FrontendURL string CookieDomain string CookieSecure bool SessionTTLHr int } func LoadEnvConfig() Config { return Config{ AppPort: envString("APP_PORT", "3000"), AppEnv: envString("APP_ENV", "development"), SAMLCertFile: envString("SAML_CERT_FILE", "configs/saml.crt"), SAMLKeyFile: envString("SAML_KEY_FILE", "configs/saml.key"), SAMLMetadataFile: envString("SAML_METADATA_FILE", "configs/metadata.xml"), SAMLRootURL: envString("SAML_ROOT_URL", "https://api-wfm-dev.mybit.co.id"), RedisAddr: envString("REDIS_ADDR", "localhost:6379"), RedisPassword: envString("REDIS_PASSWORD", ""), RedisDB: envInt("REDIS_DB", 0), FrontendURL: envString("FRONTEND_URL", "https://wfm-dev.mybit.co.id"), CookieDomain: strings.TrimSpace(os.Getenv("COOKIE_DOMAIN")), CookieSecure: envBool("COOKIE_SECURE", true), SessionTTLHour: envInt("SESSION_TTL_HOUR", 8), } } func envString(key, fallback string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } return v } func envInt(key string, fallback int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } func envBool(key string, fallback bool) bool { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { return fallback } return b } type MySQLConfig struct { DSN string MaxOpenConns int MaxIdleConns int ConnMaxLifetime time.Duration ConnMaxIdleTime time.Duration } type AuthConfig struct { PublicBaseURL string PasswordPepper string IPEncryptionKey string HashTime uint32 HashMemoryKB uint32 HashThreads uint8 HashKeyLen uint32 EmailVerifyTTL time.Duration EmailVerifyURL string InviteTTL time.Duration SetPasswordURL string PasswordResetTTL time.Duration PasswordResetURL string SecurityPINResetTTL time.Duration SecurityPINResetURL string ForgotSecurityPINCooldown time.Duration ForgotSecurityPINMinDuration time.Duration SecurityPINMaxAttempts int SecurityPINLockDuration time.Duration SecurityPINActionTokenTTL time.Duration ForgotPasswordEmailCooldown time.Duration ForgotPasswordMinDuration time.Duration ForgotPasswordIPRateMax int ForgotPasswordIPRateWindow time.Duration LoginIPRateMax int LoginIPRateWindow time.Duration TOTPChallengeTTL time.Duration EmailOTPChallengeTTL time.Duration EmailOTPLength int EmailOTPExpiry time.Duration EmailOTPMaxAttempts int EmailOTPResendCooldown time.Duration EmailOTPMaxResendPerHour int SSOStateTTL time.Duration TOTPIssuer string TOTPSecretKeyB64 string JWTAccessSecret string JWTRefreshSecret string JWTAccessTTL time.Duration JWTRefreshTTL time.Duration JWTRefreshTOTPTTL time.Duration JWTLocalRefreshTTL time.Duration JWTSSORefreshTTL time.Duration SessionIdleTTL time.Duration JWTCookieDomain string JWTCookieSecure bool JWTCookieSameSite string JWTAccessCookieName string JWTRefreshCookieName string WebAuthnRPID string WebAuthnRPDisplayName string WebAuthnRPOrigins []string WebAuthnChallengeTTL time.Duration SSOSuccessRedirectURL string DefaultRoleName string DisableRegister bool } type SESConfig struct { Region string Endpoint string From string ConfigurationSetName string SendTimeout time.Duration AllowInsecureEndpoint bool } type EmailDeliveryConfig struct { Provider string MockDir string } type MicrosoftSSOConfig struct { TenantID string ClientID string ClientSecret string RedirectURL string Authority string Scopes []string } type HTTPConfig struct { BodyLimit int ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration RequestTimeout time.Duration RequestTimeoutByPath map[string]time.Duration ShutdownTimeout time.Duration CORSAllowOrigins string CORSAllowMethods string CORSAllowHeaders string CORSExposeHeaders string CORSAllowCredentials bool CORSMaxAge int RateLimitMax int RateLimitWindow time.Duration RateLimitByEndpoint map[string]RateLimitRule HSTSMaxAge int } type RateLimitRule struct { Max int Window time.Duration } type AuditConfig struct { QueueSize int Workers int } type LoggingConfig struct { Level string Format string } type CircuitBreakerPolicy struct { Enabled bool MaxRequests uint32 Interval time.Duration BucketPeriod time.Duration Timeout time.Duration MinRequests uint32 FailureRatio float64 ConsecutiveFailures uint32 } type ResilienceConfig struct { Default CircuitBreakerPolicy MicrosoftSSO CircuitBreakerPolicy SES CircuitBreakerPolicy SQS CircuitBreakerPolicy } type QueueConfig struct { MessageSchemaVersion string EnableLegacyPayloadFallback bool IdempotencyKeyPrefix string IdempotencyTTL time.Duration ProcessingTTL time.Duration WorkerConcurrency int WorkerMaxBatchSize int WorkerProcessTimeout time.Duration WorkerShutdownTimeout time.Duration WorkerBackoffMin time.Duration WorkerBackoffMax time.Duration WorkerPermanentFailureDelay time.Duration WorkerMonitorAddr string WorkerHealthErrorThreshold int WorkerDepthPollInterval time.Duration OutboxEnabled bool OutboxPollInterval time.Duration OutboxBatchSize int OutboxDispatchTimeout time.Duration OutboxLockTTL time.Duration OutboxMaxAttempts int } type FileQueueConfig struct { MessageSchemaVersion string IdempotencyKeyPrefix string IdempotencyTTL time.Duration ProcessingTTL time.Duration WorkerConcurrency int WorkerMaxBatchSize int WorkerProcessTimeout time.Duration WorkerShutdownTimeout time.Duration WorkerBackoffMin time.Duration WorkerBackoffMax time.Duration WorkerPermanentFailureDelay time.Duration WorkerMonitorAddr string WorkerHealthErrorThreshold int WorkerDepthPollInterval time.Duration OutboxEnabled bool OutboxPollInterval time.Duration OutboxBatchSize int OutboxDispatchTimeout time.Duration OutboxLockTTL time.Duration OutboxMaxAttempts int } type SQSConfig struct { Region string AccessKeyID string SecretAccessKey string Endpoint string QueueURL string QueueType string MessageGroupID string MaxMessages int WaitTimeSeconds int32 VisibilityTimeout time.Duration AllowInsecureEndpoint bool } type FileStorageConfig struct { Provider string Bucket string Region string Endpoint string AccessKeyID string SecretAccessKey string ForcePathStyle bool AllowInsecureEndpoint bool AutoCreateBucket bool ObjectKeyPrefix string PresignPutTTL time.Duration PresignGetTTL time.Duration } type FileManagerUploadPolicyConfig struct { MaxFileSizeBytes int64 AllowedMimeTypes []string AllowedExtensions []string AllowEmptyFile bool MaxFilenameLength int } type FileManagerTrashPolicyConfig struct { AutoPurgeEnabled bool Retention time.Duration AutoPurgeInterval time.Duration AutoPurgeBatchSize int } type FileManagerLifecycleConfig struct { EnableS3Tagging bool EnableCleanupJob bool TempUploadGraceHours int OrphanGraceDays int CleanupBatchSize int CleanupDryRun bool } type WOPIConfig struct { TokenSecret string ProofSecret string DiscoveryURL string PublicBaseURL string EditActionURL string TokenTTL time.Duration MaxBodyBytes int RequireProof bool } type GotenbergConfig struct { URL string } func LoadMySQLConfigFromEnv() MySQLConfig { return MySQLConfig{ DSN: getenv("MYSQL_DSN", ""), MaxOpenConns: getenvInt("MYSQL_MAX_OPEN_CONNS", 25), MaxIdleConns: getenvInt("MYSQL_MAX_IDLE_CONNS", 10), ConnMaxLifetime: getenvDuration("MYSQL_CONN_MAX_LIFETIME", 5*time.Minute), ConnMaxIdleTime: getenvDuration("MYSQL_CONN_MAX_IDLE_TIME", 2*time.Minute), } } func LoadAuthConfigFromEnv() AuthConfig { refreshTTL := getenvDuration("AUTH_JWT_REFRESH_TTL", time.Hour) refreshTOTPTTL := getenvDuration("AUTH_JWT_REFRESH_TOTP_TTL", refreshTTL) passwordPepper := getenv("AUTH_PASSWORD_PEPPER", "") ipEncryptionKey := getenv("AUTH_IP_ENCRYPTION_KEY", getenv("AUTH_IP_HMAC_KEY", passwordPepper)) return AuthConfig{ PublicBaseURL: getenv("APP_PUBLIC_BASE_URL", ""), PasswordPepper: passwordPepper, IPEncryptionKey: ipEncryptionKey, HashTime: uint32(getenvInt("AUTH_HASH_TIME", 1)), HashMemoryKB: uint32(getenvInt("AUTH_HASH_MEMORY_KB", 64*1024)), HashThreads: uint8(getenvInt("AUTH_HASH_THREADS", 4)), HashKeyLen: uint32(getenvInt("AUTH_HASH_KEY_LEN", 32)), EmailVerifyTTL: getenvDuration("AUTH_EMAIL_VERIFY_TTL", 24*time.Hour), EmailVerifyURL: getenv("AUTH_EMAIL_VERIFY_URL", ""), InviteTTL: getenvDuration("AUTH_INVITE_TTL", 24*time.Hour), SetPasswordURL: getenv("AUTH_SET_PASSWORD_URL", getenv("AUTH_EMAIL_VERIFY_URL", "")), PasswordResetTTL: getenvDuration("AUTH_PASSWORD_RESET_TTL", 20*time.Minute), PasswordResetURL: getenv("AUTH_PASSWORD_RESET_URL", getenv("AUTH_SET_PASSWORD_URL", "")), SecurityPINResetTTL: getenvDuration("AUTH_SECURITY_PIN_RESET_TTL", 20*time.Minute), SecurityPINResetURL: getenv("AUTH_SECURITY_PIN_RESET_URL", getenv("AUTH_PASSWORD_RESET_URL", getenv("AUTH_SET_PASSWORD_URL", ""))), ForgotSecurityPINCooldown: getenvDuration("AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN", 2*time.Minute), ForgotSecurityPINMinDuration: getenvDuration("AUTH_FORGOT_SECURITY_PIN_MIN_DURATION", 150*time.Millisecond), SecurityPINMaxAttempts: getenvInt("AUTH_SECURITY_PIN_MAX_ATTEMPTS", 5), SecurityPINLockDuration: getenvDuration("AUTH_SECURITY_PIN_LOCK_DURATION", 0), SecurityPINActionTokenTTL: getenvDuration("AUTH_SECURITY_PIN_ACTION_TOKEN_TTL", 5*time.Minute), ForgotPasswordEmailCooldown: getenvDuration("AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN", 2*time.Minute), ForgotPasswordMinDuration: getenvDuration("AUTH_FORGOT_PASSWORD_MIN_DURATION", 150*time.Millisecond), ForgotPasswordIPRateMax: getenvInt("AUTH_FORGOT_PASSWORD_IP_RATE_MAX", 10), ForgotPasswordIPRateWindow: getenvDuration("AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW", 15*time.Minute), LoginIPRateMax: getenvInt("AUTH_LOGIN_IP_RATE_MAX", 5), LoginIPRateWindow: getenvDuration("AUTH_LOGIN_IP_RATE_WINDOW", time.Minute), TOTPChallengeTTL: getenvDuration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute), EmailOTPChallengeTTL: getenvDuration("AUTH_EMAIL_OTP_CHALLENGE_TTL", 10*time.Minute), EmailOTPLength: getenvInt("AUTH_EMAIL_OTP_LENGTH", 6), EmailOTPExpiry: getenvDuration("AUTH_EMAIL_OTP_EXPIRY", 10*time.Minute), EmailOTPMaxAttempts: getenvInt("AUTH_EMAIL_OTP_MAX_ATTEMPTS", 5), EmailOTPResendCooldown: getenvDuration("AUTH_EMAIL_OTP_RESEND_COOLDOWN", 60*time.Second), EmailOTPMaxResendPerHour: getenvInt("AUTH_EMAIL_OTP_MAX_RESEND_PER_HOUR", 5), SSOStateTTL: getenvDuration("AUTH_SSO_STATE_TTL", 10*time.Minute), TOTPIssuer: getenv("TOTP_ISSUER", "Wucher"), TOTPSecretKeyB64: getenv("TOTP_SECRET_KEY", ""), JWTAccessSecret: getenv("AUTH_JWT_ACCESS_SECRET", ""), JWTRefreshSecret: getenv("AUTH_JWT_REFRESH_SECRET", ""), JWTAccessTTL: getenvDuration("AUTH_JWT_ACCESS_TTL", 10*time.Minute), JWTRefreshTTL: refreshTTL, JWTRefreshTOTPTTL: refreshTOTPTTL, JWTLocalRefreshTTL: getenvDuration("AUTH_JWT_LOCAL_REFRESH_TTL", 24*time.Hour), // Fallback lifetime for Microsoft/SSO sessions when Microsoft does not // report refresh_token_expires_in. Defaults to Microsoft's default // refresh-token lifetime (90 days); the actual session follows the // value Microsoft returns whenever it is present. JWTSSORefreshTTL: getenvDuration("AUTH_JWT_SSO_REFRESH_TTL", 90*24*time.Hour), SessionIdleTTL: getenvDuration("AUTH_SESSION_IDLE_TTL", 15*time.Minute), JWTCookieDomain: getenv("AUTH_JWT_COOKIE_DOMAIN", ""), JWTCookieSecure: getenvBool("AUTH_JWT_COOKIE_SECURE", false), JWTCookieSameSite: getenv("AUTH_JWT_COOKIE_SAMESITE", "Lax"), JWTAccessCookieName: getenv("AUTH_JWT_ACCESS_COOKIE", "wucher_at"), JWTRefreshCookieName: getenv("AUTH_JWT_REFRESH_COOKIE", "wucher_rt"), WebAuthnRPID: getenv("AUTH_WEBAUTHN_RP_ID", ""), WebAuthnRPDisplayName: getenv("AUTH_WEBAUTHN_RP_DISPLAY_NAME", "Wucher"), WebAuthnRPOrigins: getenvCSV("AUTH_WEBAUTHN_RP_ORIGINS", ""), WebAuthnChallengeTTL: getenvDuration("AUTH_WEBAUTHN_CHALLENGE_TTL", 5*time.Minute), SSOSuccessRedirectURL: getenv("AUTH_SSO_SUCCESS_REDIRECT", ""), DefaultRoleName: getenv("AUTH_DEFAULT_ROLE", "user"), DisableRegister: getenvBool("AUTH_DISABLE_REGISTER", false), } } func LoadSESConfigFromEnv() SESConfig { return SESConfig{ Region: getenv("SES_REGION", getenv("AWS_REGION", "")), Endpoint: getenv("SES_ENDPOINT", ""), From: getenv("SES_FROM", ""), ConfigurationSetName: getenv("SES_CONFIGURATION_SET", ""), SendTimeout: getenvDuration("SES_SEND_TIMEOUT", 10*time.Second), AllowInsecureEndpoint: getenvBool("SES_ALLOW_INSECURE_ENDPOINT", false), } } func LoadEmailDeliveryConfigFromEnv() EmailDeliveryConfig { return EmailDeliveryConfig{ Provider: strings.ToLower(strings.TrimSpace(getenv("EMAIL_PROVIDER", "ses"))), MockDir: strings.TrimSpace(getenv("EMAIL_MOCK_DIR", ".local/emails")), } } func LoadMicrosoftSSOConfigFromEnv() MicrosoftSSOConfig { scopes := strings.Split(getenv("MS_ENTRA_SCOPES", "openid,profile,email,offline_access,User.Read"), ",") for i := range scopes { scopes[i] = strings.TrimSpace(scopes[i]) } return MicrosoftSSOConfig{ TenantID: getenv("MS_ENTRA_TENANT_ID", ""), ClientID: getenv("MS_ENTRA_CLIENT_ID", ""), ClientSecret: getenv("MS_ENTRA_CLIENT_SECRET", ""), RedirectURL: getenv("MS_ENTRA_REDIRECT_URL", ""), Authority: getenv("MS_ENTRA_AUTHORITY", "https://login.microsoftonline.com/"), Scopes: scopes, } } func LoadHTTPConfigFromEnv() HTTPConfig { return HTTPConfig{ BodyLimit: getenvInt("HTTP_BODY_LIMIT", 4*1024*1024), ReadTimeout: getenvDuration("HTTP_READ_TIMEOUT", 10*time.Second), WriteTimeout: getenvDuration("HTTP_WRITE_TIMEOUT", 15*time.Second), IdleTimeout: getenvDuration("HTTP_IDLE_TIMEOUT", 60*time.Second), RequestTimeout: getenvDuration("HTTP_REQUEST_TIMEOUT", 30*time.Second), RequestTimeoutByPath: parseHTTPTimeoutOverrides(getenv("HTTP_REQUEST_TIMEOUT_OVERRIDES", "")), ShutdownTimeout: getenvDuration("HTTP_SHUTDOWN_TIMEOUT", 30*time.Second), CORSAllowOrigins: getenv("HTTP_CORS_ALLOW_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173"), CORSAllowMethods: getenv("HTTP_CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD"), CORSAllowHeaders: getenv("HTTP_CORS_ALLOW_HEADERS", "Origin,Content-Type,Accept,Authorization,X-Requested-With,X-Pin-Verification-Token,X-Device-Type"), CORSExposeHeaders: getenv("HTTP_CORS_EXPOSE_HEADERS", "X-Request-ID"), CORSAllowCredentials: getenvBool("HTTP_CORS_ALLOW_CREDENTIALS", true), CORSMaxAge: getenvInt("HTTP_CORS_MAX_AGE", 300), RateLimitMax: getenvInt("HTTP_RATE_LIMIT_MAX", 120), RateLimitWindow: getenvDuration("HTTP_RATE_LIMIT_WINDOW", time.Minute), RateLimitByEndpoint: parseHTTPEndpointRateLimitOverrides(getenv("HTTP_RATE_LIMIT_ENDPOINTS", "")), HSTSMaxAge: getenvInt("HTTP_SECURITY_HSTS_MAX_AGE", 0), } } func parseHTTPTimeoutOverrides(raw string) map[string]time.Duration { parsed := make(map[string]time.Duration) if strings.TrimSpace(raw) == "" { return parsed } entries := strings.Split(raw, ",") for _, entry := range entries { entry = strings.TrimSpace(entry) if entry == "" { continue } parts := strings.SplitN(entry, "=", 2) if len(parts) != 2 { continue } keyParts := strings.Fields(strings.TrimSpace(parts[0])) if len(keyParts) < 2 { continue } method := strings.ToUpper(strings.TrimSpace(keyParts[0])) path := strings.Join(keyParts[1:], " ") path = strings.TrimSpace(path) if method == "" || path == "" { continue } key := method + " " + path duration, err := time.ParseDuration(strings.TrimSpace(parts[1])) if err != nil || duration <= 0 { continue } parsed[key] = duration } return parsed } func parseHTTPEndpointRateLimitOverrides(raw string) map[string]RateLimitRule { parsed := make(map[string]RateLimitRule) if strings.TrimSpace(raw) == "" { return parsed } entries := strings.Split(raw, ";") for _, entry := range entries { entry = strings.TrimSpace(entry) if entry == "" { continue } parts := strings.SplitN(entry, "=", 2) if len(parts) != 2 { continue } keyParts := strings.Fields(strings.TrimSpace(parts[0])) if len(keyParts) < 2 { continue } method := strings.ToUpper(strings.TrimSpace(keyParts[0])) path := strings.Join(keyParts[1:], " ") path = strings.TrimSpace(path) if method == "" || path == "" { continue } key := method + " " + path ruleParts := strings.SplitN(strings.TrimSpace(parts[1]), ",", 2) if len(ruleParts) != 2 { continue } max, err := strconv.Atoi(strings.TrimSpace(ruleParts[0])) if err != nil || max <= 0 { continue } window, err := time.ParseDuration(strings.TrimSpace(ruleParts[1])) if err != nil || window <= 0 { continue } parsed[key] = RateLimitRule{ Max: max, Window: window, } } return parsed } func LoadAuditConfigFromEnv() AuditConfig { return AuditConfig{ QueueSize: getenvInt("AUDIT_LOG_QUEUE_SIZE", 1024), Workers: getenvInt("AUDIT_LOG_WORKERS", 2), } } func LoadLoggingConfigFromEnv() LoggingConfig { return LoggingConfig{ Level: getenv("LOG_LEVEL", "info"), Format: getenv("LOG_FORMAT", "json"), } } func LoadResilienceConfigFromEnv() ResilienceConfig { defaults := loadCircuitBreakerPolicyFromEnv("CB_DEFAULT_", CircuitBreakerPolicy{ Enabled: true, MaxRequests: 1, Interval: time.Minute, BucketPeriod: 10 * time.Second, Timeout: 30 * time.Second, MinRequests: 5, FailureRatio: 0.6, ConsecutiveFailures: 5, }) return ResilienceConfig{ Default: defaults, MicrosoftSSO: loadCircuitBreakerPolicyFromEnv("CB_MICROSOFT_SSO_", defaults), SES: loadCircuitBreakerPolicyFromEnv("CB_SES_", defaults), SQS: loadCircuitBreakerPolicyFromEnv("CB_SQS_", defaults), } } func LoadQueueConfigFromEnv() QueueConfig { return QueueConfig{ MessageSchemaVersion: getenv("QUEUE_MESSAGE_SCHEMA_VERSION", "v2"), EnableLegacyPayloadFallback: getenvBool("QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK", true), IdempotencyKeyPrefix: getenv("QUEUE_IDEMPOTENCY_KEY_PREFIX", "queue:idempotency:email:"), IdempotencyTTL: getenvDuration("QUEUE_IDEMPOTENCY_TTL", 24*time.Hour), ProcessingTTL: getenvDuration("QUEUE_PROCESSING_TTL", 15*time.Minute), WorkerConcurrency: getenvInt("QUEUE_WORKER_CONCURRENCY", 4), WorkerMaxBatchSize: getenvInt("QUEUE_WORKER_MAX_BATCH_SIZE", 10), WorkerProcessTimeout: getenvDuration("QUEUE_WORKER_PROCESS_TIMEOUT", 30*time.Second), WorkerShutdownTimeout: getenvDuration("QUEUE_WORKER_SHUTDOWN_TIMEOUT", 30*time.Second), WorkerBackoffMin: getenvDuration("QUEUE_WORKER_BACKOFF_MIN", time.Second), WorkerBackoffMax: getenvDuration("QUEUE_WORKER_BACKOFF_MAX", 5*time.Minute), WorkerPermanentFailureDelay: getenvDuration("QUEUE_WORKER_PERMANENT_FAILURE_DELAY", 30*time.Second), WorkerMonitorAddr: getenv("QUEUE_WORKER_MONITOR_ADDR", "127.0.0.1:9090"), WorkerHealthErrorThreshold: getenvInt("QUEUE_WORKER_HEALTH_ERROR_THRESHOLD", 3), WorkerDepthPollInterval: getenvDuration("QUEUE_WORKER_DEPTH_POLL_INTERVAL", 30*time.Second), OutboxEnabled: getenvBool("QUEUE_OUTBOX_ENABLED", true), OutboxPollInterval: getenvDuration("QUEUE_OUTBOX_POLL_INTERVAL", time.Second), OutboxBatchSize: getenvInt("QUEUE_OUTBOX_BATCH_SIZE", 10), OutboxDispatchTimeout: getenvDuration("QUEUE_OUTBOX_DISPATCH_TIMEOUT", 10*time.Second), OutboxLockTTL: getenvDuration("QUEUE_OUTBOX_LOCK_TTL", time.Minute), OutboxMaxAttempts: getenvInt("QUEUE_OUTBOX_MAX_ATTEMPTS", 20), } } func LoadFileQueueConfigFromEnv() FileQueueConfig { return FileQueueConfig{ MessageSchemaVersion: getenv("FILE_QUEUE_MESSAGE_SCHEMA_VERSION", "v1"), IdempotencyKeyPrefix: getenv("FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX", "queue:idempotency:file:"), IdempotencyTTL: getenvDuration("FILE_QUEUE_IDEMPOTENCY_TTL", 24*time.Hour), ProcessingTTL: getenvDuration("FILE_QUEUE_PROCESSING_TTL", 15*time.Minute), WorkerConcurrency: getenvInt("FILE_QUEUE_WORKER_CONCURRENCY", 4), WorkerMaxBatchSize: getenvInt("FILE_QUEUE_WORKER_MAX_BATCH_SIZE", 10), WorkerProcessTimeout: getenvDuration("FILE_QUEUE_WORKER_PROCESS_TIMEOUT", 30*time.Second), WorkerShutdownTimeout: getenvDuration("FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT", 30*time.Second), WorkerBackoffMin: getenvDuration("FILE_QUEUE_WORKER_BACKOFF_MIN", time.Second), WorkerBackoffMax: getenvDuration("FILE_QUEUE_WORKER_BACKOFF_MAX", 5*time.Minute), WorkerPermanentFailureDelay: getenvDuration("FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY", 30*time.Second), WorkerMonitorAddr: getenv("FILE_QUEUE_WORKER_MONITOR_ADDR", "127.0.0.1:9091"), WorkerHealthErrorThreshold: getenvInt("FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD", 3), WorkerDepthPollInterval: getenvDuration("FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL", 30*time.Second), OutboxEnabled: getenvBool("FILE_QUEUE_OUTBOX_ENABLED", true), OutboxPollInterval: getenvDuration("FILE_QUEUE_OUTBOX_POLL_INTERVAL", time.Second), OutboxBatchSize: getenvInt("FILE_QUEUE_OUTBOX_BATCH_SIZE", 10), OutboxDispatchTimeout: getenvDuration("FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT", 10*time.Second), OutboxLockTTL: getenvDuration("FILE_QUEUE_OUTBOX_LOCK_TTL", time.Minute), OutboxMaxAttempts: getenvInt("FILE_QUEUE_OUTBOX_MAX_ATTEMPTS", 20), } } func LoadSQSConfigFromEnv() SQSConfig { return SQSConfig{ Region: getenv("AWS_REGION", ""), AccessKeyID: getenv("AWS_ACCESS_KEY_ID", ""), SecretAccessKey: getenv("AWS_SECRET_ACCESS_KEY", ""), Endpoint: getenv("SQS_ENDPOINT", ""), QueueURL: getenv("SQS_QUEUE_URL", ""), QueueType: getenv("SQS_QUEUE_TYPE", "standard"), MessageGroupID: getenv("SQS_MESSAGE_GROUP_ID", "email"), MaxMessages: getenvInt("SQS_MAX_MESSAGES", 10), WaitTimeSeconds: int32(getenvInt("SQS_WAIT_TIME_SECONDS", 20)), VisibilityTimeout: getenvDuration("SQS_VISIBILITY_TIMEOUT", 60*time.Second), AllowInsecureEndpoint: getenvBool("SQS_ALLOW_INSECURE_ENDPOINT", false), } } func LoadFileSQSConfigFromEnv() SQSConfig { return SQSConfig{ Region: getenv("FILE_SQS_REGION", getenv("AWS_REGION", "")), AccessKeyID: getenv("AWS_ACCESS_KEY_ID", ""), SecretAccessKey: getenv("AWS_SECRET_ACCESS_KEY", ""), Endpoint: getenv("FILE_SQS_ENDPOINT", getenv("SQS_ENDPOINT", "")), QueueURL: getenv("FILE_SQS_QUEUE_URL", ""), QueueType: getenv("FILE_SQS_QUEUE_TYPE", "standard"), MessageGroupID: getenv("FILE_SQS_MESSAGE_GROUP_ID", "file-processing"), MaxMessages: getenvInt("FILE_SQS_MAX_MESSAGES", 10), WaitTimeSeconds: int32(getenvInt("FILE_SQS_WAIT_TIME_SECONDS", 20)), VisibilityTimeout: getenvDuration("FILE_SQS_VISIBILITY_TIMEOUT", 60*time.Second), AllowInsecureEndpoint: getenvBool("FILE_SQS_ALLOW_INSECURE_ENDPOINT", getenvBool("SQS_ALLOW_INSECURE_ENDPOINT", false)), } } func ValidateDistinctQueueURLs(emailCfg, fileCfg SQSConfig) error { emailQueueURL := canonicalQueueURL(emailCfg.QueueURL) fileQueueURL := canonicalQueueURL(fileCfg.QueueURL) if emailQueueURL == "" || fileQueueURL == "" { return nil } if emailQueueURL == fileQueueURL { return fmt.Errorf("invalid queue configuration: FILE_SQS_QUEUE_URL must be different from SQS_QUEUE_URL") } return nil } func canonicalQueueURL(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } parsed, err := url.Parse(trimmed) if err != nil { return strings.TrimRight(strings.ToLower(trimmed), "/") } parsed.Host = strings.ToLower(parsed.Host) parsed.Path = strings.TrimRight(parsed.Path, "/") parsed.RawQuery = "" parsed.Fragment = "" return parsed.String() } func LoadFileStorageConfigFromEnv() FileStorageConfig { provider := strings.ToLower(strings.TrimSpace(getenv("FILE_STORAGE_PROVIDER", "s3"))) return FileStorageConfig{ Provider: provider, Bucket: strings.TrimSpace(getenv("FILE_S3_BUCKET", "")), Region: strings.TrimSpace(getenv("FILE_S3_REGION", getenv("AWS_REGION", ""))), Endpoint: strings.TrimSpace(getenv("FILE_S3_ENDPOINT", "")), AccessKeyID: strings.TrimSpace(getenv("FILE_S3_ACCESS_KEY_ID", getenv("AWS_ACCESS_KEY_ID", ""))), SecretAccessKey: strings.TrimSpace(getenv("FILE_S3_SECRET_ACCESS_KEY", getenv("AWS_SECRET_ACCESS_KEY", ""))), ForcePathStyle: getenvBool("FILE_S3_FORCE_PATH_STYLE", false), AllowInsecureEndpoint: getenvBool("FILE_S3_ALLOW_INSECURE_ENDPOINT", false), AutoCreateBucket: getenvBool("FILE_S3_AUTO_CREATE_BUCKET", false), ObjectKeyPrefix: strings.TrimSpace(getenv("FILE_OBJECT_KEY_PREFIX", "fm/objects")), PresignPutTTL: getenvDuration("FILE_S3_PRESIGN_PUT_TTL", 15*time.Minute), PresignGetTTL: getenvDuration("FILE_S3_PRESIGN_GET_TTL", 15*time.Minute), } } func LoadFileManagerUploadPolicyConfigFromEnv() FileManagerUploadPolicyConfig { return FileManagerUploadPolicyConfig{ MaxFileSizeBytes: int64(getenvInt("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", 4*1024*1024)), AllowedMimeTypes: normalizeMimeTypes(getenvCSV("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", "")), AllowedExtensions: normalizeExtensions(getenvCSV("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", "")), AllowEmptyFile: getenvBool("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", false), MaxFilenameLength: getenvInt("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", 255), } } func LoadFileManagerTrashPolicyConfigFromEnv() FileManagerTrashPolicyConfig { retention := getenvDuration("FILE_MANAGER_TRASH_RETENTION", 30*24*time.Hour) if retention < 0 { retention = 0 } interval := getenvDuration("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", time.Hour) if interval <= 0 { interval = time.Hour } batchSize := getenvInt("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", 200) if batchSize <= 0 { batchSize = 200 } return FileManagerTrashPolicyConfig{ AutoPurgeEnabled: getenvBool("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", true), Retention: retention, AutoPurgeInterval: interval, AutoPurgeBatchSize: batchSize, } } func LoadFileManagerLifecycleConfigFromEnv() FileManagerLifecycleConfig { tempHours := getenvInt("TEMP_UPLOAD_GRACE_HOURS", 2) if tempHours <= 0 { tempHours = 2 } orphanDays := getenvInt("ORPHAN_GRACE_PERIOD_DAYS", 30) if orphanDays <= 0 { orphanDays = 30 } batchSize := getenvInt("FILE_CLEANUP_BATCH_SIZE", 100) if batchSize <= 0 { batchSize = 100 } return FileManagerLifecycleConfig{ EnableS3Tagging: getenvBool("ENABLE_S3_TAGGING", true), EnableCleanupJob: getenvBool("ENABLE_FILE_CLEANUP_JOB", false), TempUploadGraceHours: tempHours, OrphanGraceDays: orphanDays, CleanupBatchSize: batchSize, CleanupDryRun: getenvBool("FILE_CLEANUP_DRY_RUN", true), } } func getenv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func getenvInt(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } return def } func getenvUint32(key string, def uint32) uint32 { if v := os.Getenv(key); v != "" { if n, err := strconv.ParseUint(v, 10, 32); err == nil { return uint32(n) } } return def } func getenvFloat64(key string, def float64) float64 { if v := os.Getenv(key); v != "" { if n, err := strconv.ParseFloat(v, 64); err == nil { return n } } return def } func getenvDuration(key string, def time.Duration) time.Duration { if v := os.Getenv(key); v != "" { if d, err := time.ParseDuration(v); err == nil { return d } } return def } func getenvBool(key string, def bool) bool { if v := os.Getenv(key); v != "" { if b, err := strconv.ParseBool(v); err == nil { return b } } return def } func getenvCSV(key, def string) []string { raw := os.Getenv(key) if strings.TrimSpace(raw) == "" { raw = def } raw = strings.TrimSpace(raw) if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for i := range parts { part := strings.TrimSpace(parts[i]) if part == "" { continue } out = append(out, part) } return out } func normalizeMimeTypes(values []string) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for i := range values { v := strings.ToLower(strings.TrimSpace(values[i])) if idx := strings.Index(v, ";"); idx >= 0 { v = strings.TrimSpace(v[:idx]) } if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func normalizeExtensions(values []string) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for i := range values { v := strings.ToLower(strings.TrimSpace(values[i])) v = strings.TrimPrefix(v, ".") if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func loadCircuitBreakerPolicyFromEnv(prefix string, defaults CircuitBreakerPolicy) CircuitBreakerPolicy { return CircuitBreakerPolicy{ Enabled: getenvBool(prefix+"ENABLED", defaults.Enabled), MaxRequests: getenvUint32(prefix+"MAX_REQUESTS", defaults.MaxRequests), Interval: getenvDuration(prefix+"INTERVAL", defaults.Interval), BucketPeriod: getenvDuration(prefix+"BUCKET_PERIOD", defaults.BucketPeriod), Timeout: getenvDuration(prefix+"TIMEOUT", defaults.Timeout), MinRequests: getenvUint32(prefix+"MIN_REQUESTS", defaults.MinRequests), FailureRatio: getenvFloat64(prefix+"FAILURE_RATIO", defaults.FailureRatio), ConsecutiveFailures: getenvUint32(prefix+"CONSECUTIVE_FAILURES", defaults.ConsecutiveFailures), } }