init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

928
internal/config/config.go Normal file
View File

@@ -0,0 +1,928 @@
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),
}
}