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),
}
}

View File

@@ -0,0 +1,340 @@
package config
import (
"testing"
"time"
)
func TestLoadFileManagerUploadPolicyConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", "")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", "")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", "")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", "")
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", "")
cfg := LoadFileManagerUploadPolicyConfigFromEnv()
if cfg.MaxFileSizeBytes != 4*1024*1024 {
t.Fatalf("expected default max file size 4194304, got %d", cfg.MaxFileSizeBytes)
}
if cfg.AllowEmptyFile {
t.Fatalf("expected default allow empty file false")
}
if cfg.MaxFilenameLength != 255 {
t.Fatalf("expected default max filename length 255, got %d", cfg.MaxFilenameLength)
}
if len(cfg.AllowedMimeTypes) != 0 {
t.Fatalf("expected empty default allowed mime types, got %v", cfg.AllowedMimeTypes)
}
if len(cfg.AllowedExtensions) != 0 {
t.Fatalf("expected empty default allowed extensions, got %v", cfg.AllowedExtensions)
}
}
func TestLoadFileManagerUploadPolicyConfigFromEnv_Normalize(t *testing.T) {
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", "1048576")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", " image/PNG ; charset=utf-8,application/pdf,IMAGE/PNG")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", " .PDF, png ,PNG")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", "true")
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", "120")
cfg := LoadFileManagerUploadPolicyConfigFromEnv()
if cfg.MaxFileSizeBytes != 1048576 {
t.Fatalf("expected max file size 1048576, got %d", cfg.MaxFileSizeBytes)
}
if !cfg.AllowEmptyFile {
t.Fatalf("expected allow empty file true")
}
if cfg.MaxFilenameLength != 120 {
t.Fatalf("expected max filename length 120, got %d", cfg.MaxFilenameLength)
}
if len(cfg.AllowedMimeTypes) != 2 || cfg.AllowedMimeTypes[0] != "image/png" || cfg.AllowedMimeTypes[1] != "application/pdf" {
t.Fatalf("unexpected normalized mime types: %v", cfg.AllowedMimeTypes)
}
if len(cfg.AllowedExtensions) != 2 || cfg.AllowedExtensions[0] != "pdf" || cfg.AllowedExtensions[1] != "png" {
t.Fatalf("unexpected normalized extensions: %v", cfg.AllowedExtensions)
}
}
func TestLoadFileManagerTrashPolicyConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", "")
t.Setenv("FILE_MANAGER_TRASH_RETENTION", "")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", "")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", "")
cfg := LoadFileManagerTrashPolicyConfigFromEnv()
if !cfg.AutoPurgeEnabled {
t.Fatalf("expected default auto purge enabled")
}
if cfg.Retention != 30*24*time.Hour {
t.Fatalf("expected default retention 720h, got %s", cfg.Retention)
}
if cfg.AutoPurgeInterval != time.Hour {
t.Fatalf("expected default auto purge interval 1h, got %s", cfg.AutoPurgeInterval)
}
if cfg.AutoPurgeBatchSize != 200 {
t.Fatalf("expected default batch size 200, got %d", cfg.AutoPurgeBatchSize)
}
}
func TestLoadFileManagerTrashPolicyConfigFromEnv_Custom(t *testing.T) {
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", "false")
t.Setenv("FILE_MANAGER_TRASH_RETENTION", "168h")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", "15m")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", "50")
cfg := LoadFileManagerTrashPolicyConfigFromEnv()
if cfg.AutoPurgeEnabled {
t.Fatalf("expected auto purge disabled")
}
if cfg.Retention != 168*time.Hour {
t.Fatalf("expected retention 168h, got %s", cfg.Retention)
}
if cfg.AutoPurgeInterval != 15*time.Minute {
t.Fatalf("expected auto purge interval 15m, got %s", cfg.AutoPurgeInterval)
}
if cfg.AutoPurgeBatchSize != 50 {
t.Fatalf("expected batch size 50, got %d", cfg.AutoPurgeBatchSize)
}
}
func TestLoadFileManagerLifecycleConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("ENABLE_S3_TAGGING", "")
t.Setenv("ENABLE_FILE_CLEANUP_JOB", "")
t.Setenv("TEMP_UPLOAD_GRACE_HOURS", "")
t.Setenv("ORPHAN_GRACE_PERIOD_DAYS", "")
t.Setenv("FILE_CLEANUP_BATCH_SIZE", "")
t.Setenv("FILE_CLEANUP_DRY_RUN", "")
cfg := LoadFileManagerLifecycleConfigFromEnv()
if !cfg.EnableS3Tagging {
t.Fatalf("expected s3 tagging enabled by default")
}
if cfg.EnableCleanupJob {
t.Fatalf("expected cleanup job disabled by default")
}
if cfg.TempUploadGraceHours != 2 {
t.Fatalf("expected temp upload grace 2 hours, got %d", cfg.TempUploadGraceHours)
}
if cfg.OrphanGraceDays != 30 {
t.Fatalf("expected orphan grace 30 days, got %d", cfg.OrphanGraceDays)
}
if cfg.CleanupBatchSize != 100 {
t.Fatalf("expected cleanup batch size 100, got %d", cfg.CleanupBatchSize)
}
if !cfg.CleanupDryRun {
t.Fatalf("expected cleanup dry run enabled by default")
}
}
func TestLoadFileManagerLifecycleConfigFromEnv_Custom(t *testing.T) {
t.Setenv("ENABLE_S3_TAGGING", "false")
t.Setenv("ENABLE_FILE_CLEANUP_JOB", "true")
t.Setenv("TEMP_UPLOAD_GRACE_HOURS", "6")
t.Setenv("ORPHAN_GRACE_PERIOD_DAYS", "45")
t.Setenv("FILE_CLEANUP_BATCH_SIZE", "250")
t.Setenv("FILE_CLEANUP_DRY_RUN", "false")
cfg := LoadFileManagerLifecycleConfigFromEnv()
if cfg.EnableS3Tagging {
t.Fatalf("expected s3 tagging disabled")
}
if !cfg.EnableCleanupJob {
t.Fatalf("expected cleanup job enabled")
}
if cfg.TempUploadGraceHours != 6 {
t.Fatalf("expected temp upload grace 6 hours, got %d", cfg.TempUploadGraceHours)
}
if cfg.OrphanGraceDays != 45 {
t.Fatalf("expected orphan grace 45 days, got %d", cfg.OrphanGraceDays)
}
if cfg.CleanupBatchSize != 250 {
t.Fatalf("expected cleanup batch size 250, got %d", cfg.CleanupBatchSize)
}
if cfg.CleanupDryRun {
t.Fatalf("expected cleanup dry run disabled")
}
}
func TestLoadFileQueueConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("FILE_QUEUE_MESSAGE_SCHEMA_VERSION", "")
t.Setenv("FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX", "")
t.Setenv("FILE_QUEUE_WORKER_MONITOR_ADDR", "")
t.Setenv("FILE_QUEUE_OUTBOX_ENABLED", "")
t.Setenv("FILE_QUEUE_OUTBOX_POLL_INTERVAL", "")
t.Setenv("FILE_QUEUE_OUTBOX_BATCH_SIZE", "")
t.Setenv("FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT", "")
t.Setenv("FILE_QUEUE_OUTBOX_LOCK_TTL", "")
t.Setenv("FILE_QUEUE_OUTBOX_MAX_ATTEMPTS", "")
cfg := LoadFileQueueConfigFromEnv()
if cfg.MessageSchemaVersion != "v1" {
t.Fatalf("expected default schema version v1, got %q", cfg.MessageSchemaVersion)
}
if cfg.IdempotencyKeyPrefix != "queue:idempotency:file:" {
t.Fatalf("unexpected default idempotency prefix: %q", cfg.IdempotencyKeyPrefix)
}
if cfg.WorkerMonitorAddr != "127.0.0.1:9091" {
t.Fatalf("unexpected default monitor addr: %q", cfg.WorkerMonitorAddr)
}
if !cfg.OutboxEnabled {
t.Fatalf("expected default file outbox enabled")
}
if cfg.OutboxPollInterval != time.Second {
t.Fatalf("expected default outbox poll interval 1s, got %s", cfg.OutboxPollInterval)
}
if cfg.OutboxBatchSize != 10 {
t.Fatalf("expected default outbox batch size 10, got %d", cfg.OutboxBatchSize)
}
if cfg.OutboxDispatchTimeout != 10*time.Second {
t.Fatalf("expected default outbox dispatch timeout 10s, got %s", cfg.OutboxDispatchTimeout)
}
if cfg.OutboxLockTTL != time.Minute {
t.Fatalf("expected default outbox lock ttl 1m, got %s", cfg.OutboxLockTTL)
}
if cfg.OutboxMaxAttempts != 20 {
t.Fatalf("expected default outbox max attempts 20, got %d", cfg.OutboxMaxAttempts)
}
}
func TestLoadFileSQSConfigFromEnv(t *testing.T) {
t.Setenv("AWS_REGION", "ap-southeast-1")
t.Setenv("SQS_ENDPOINT", "http://localhost:4566")
t.Setenv("SQS_ALLOW_INSECURE_ENDPOINT", "true")
t.Setenv("FILE_SQS_REGION", "")
t.Setenv("FILE_SQS_ENDPOINT", "")
t.Setenv("FILE_SQS_QUEUE_URL", "http://localhost:4566/000000000000/app-file-queue")
t.Setenv("FILE_SQS_QUEUE_TYPE", "standard")
t.Setenv("FILE_SQS_MESSAGE_GROUP_ID", "file")
t.Setenv("FILE_SQS_MAX_MESSAGES", "8")
t.Setenv("FILE_SQS_WAIT_TIME_SECONDS", "15")
t.Setenv("FILE_SQS_VISIBILITY_TIMEOUT", "90s")
t.Setenv("FILE_SQS_ALLOW_INSECURE_ENDPOINT", "")
cfg := LoadFileSQSConfigFromEnv()
if cfg.Region != "ap-southeast-1" {
t.Fatalf("unexpected region: %q", cfg.Region)
}
if cfg.Endpoint != "http://localhost:4566" {
t.Fatalf("unexpected endpoint: %q", cfg.Endpoint)
}
if cfg.QueueURL == "" {
t.Fatalf("expected queue url")
}
if cfg.MaxMessages != 8 {
t.Fatalf("unexpected max messages: %d", cfg.MaxMessages)
}
if cfg.WaitTimeSeconds != 15 {
t.Fatalf("unexpected wait time seconds: %d", cfg.WaitTimeSeconds)
}
if !cfg.AllowInsecureEndpoint {
t.Fatalf("expected insecure endpoint allowed from fallback")
}
}
func TestValidateDistinctQueueURLs(t *testing.T) {
t.Run("different queue urls", func(t *testing.T) {
err := ValidateDistinctQueueURLs(
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue"},
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-file-queue"},
)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
t.Run("same queue urls with different case and slash", func(t *testing.T) {
err := ValidateDistinctQueueURLs(
SQSConfig{QueueURL: "HTTP://LOCALHOST:4566/000000000000/app-queue"},
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue/"},
)
if err == nil {
t.Fatalf("expected error for same queue urls")
}
})
t.Run("skip when one queue url empty", func(t *testing.T) {
err := ValidateDistinctQueueURLs(
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue"},
SQSConfig{QueueURL: ""},
)
if err != nil {
t.Fatalf("expected no error when file queue url is empty, got %v", err)
}
})
}
func TestParseHTTPTimeoutOverrides(t *testing.T) {
t.Run("invalid format ignored", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id 100ms,foo")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("invalid duration ignored", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id=not-a-duration")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("zero and negative duration ignored", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id=0s,POST /users=-1s")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("valid entries parsed", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id=120ms, POST /auth/login = 2s")
if len(parsed) != 2 {
t.Fatalf("expected 2 parsed entries, got %d", len(parsed))
}
if parsed["GET /users/:id"] != 120*time.Millisecond {
t.Fatalf("unexpected GET /users/:id timeout: %s", parsed["GET /users/:id"])
}
if parsed["POST /auth/login"] != 2*time.Second {
t.Fatalf("unexpected POST /auth/login timeout: %s", parsed["POST /auth/login"])
}
})
}
func TestParseHTTPEndpointRateLimitOverrides(t *testing.T) {
t.Run("invalid entries ignored", func(t *testing.T) {
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=abc,1m;foo;GET /x=1")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("zero or negative values ignored", func(t *testing.T) {
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=0,1m;POST /auth/totp/verify=5,0s")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("valid entries parsed", func(t *testing.T) {
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=5,1m; POST /auth/totp/verify = 3,30s")
if len(parsed) != 2 {
t.Fatalf("expected 2 parsed entries, got %d", len(parsed))
}
login, ok := parsed["POST /auth/login"]
if !ok {
t.Fatalf("expected POST /auth/login to be parsed")
}
if login.Max != 5 || login.Window != time.Minute {
t.Fatalf("unexpected login rule: %+v", login)
}
totp, ok := parsed["POST /auth/totp/verify"]
if !ok {
t.Fatalf("expected POST /auth/totp/verify to be parsed")
}
if totp.Max != 3 || totp.Window != 30*time.Second {
t.Fatalf("unexpected totp rule: %+v", totp)
}
})
}

638
internal/config/loader.go Normal file
View File

@@ -0,0 +1,638 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Duration unmarshals a Go duration string (e.g. "30s", "5m", "150ms") from JSON.
// An empty string is treated as zero duration.
type Duration struct{ time.Duration }
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
if s == "" {
d.Duration = 0
return nil
}
dur, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", s, err)
}
d.Duration = dur
return nil
}
// AppConfig is the unified config loaded from a JSON file.
type AppConfig struct {
HTTPPort int
MySQL MySQLConfig
Logging LoggingConfig
Audit AuditConfig
Auth AuthConfig
SES SESConfig
Email EmailDeliveryConfig
MicrosoftSSO MicrosoftSSOConfig
HTTP HTTPConfig
Resilience ResilienceConfig
Queue QueueConfig
FileQueue FileQueueConfig
SQS SQSConfig
FileSQS SQSConfig
FileStorage FileStorageConfig
FileUpload FileManagerUploadPolicyConfig
SAML SAMLConfig
WOPI WOPIConfig
Gotenberg GotenbergConfig
}
// LoadFromFile reads path, parses it as JSON, and returns a populated AppConfig.
// The file is typically produced by scripts/inject-secrets.sh.
func LoadFromFile(path string) (AppConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return AppConfig{}, fmt.Errorf("config: read %q: %w", path, err)
}
var raw jsonAppConfig
if err := json.Unmarshal(data, &raw); err != nil {
return AppConfig{}, fmt.Errorf("config: parse %q: %w", path, err)
}
return raw.toAppConfig(), nil
}
// ── JSON intermediate types ───────────────────────────────────────────────────
type jsonCircuitBreakerPolicy struct {
Enabled bool `json:"enabled"`
MaxRequests uint32 `json:"maxRequests"`
Interval Duration `json:"interval"`
BucketPeriod Duration `json:"bucketPeriod"`
Timeout Duration `json:"timeout"`
MinRequests uint32 `json:"minRequests"`
FailureRatio float64 `json:"failureRatio"`
ConsecutiveFailures uint32 `json:"consecutiveFailures"`
}
func (j jsonCircuitBreakerPolicy) toPolicy() CircuitBreakerPolicy {
return CircuitBreakerPolicy{
Enabled: j.Enabled,
MaxRequests: j.MaxRequests,
Interval: j.Interval.Duration,
BucketPeriod: j.BucketPeriod.Duration,
Timeout: j.Timeout.Duration,
MinRequests: j.MinRequests,
FailureRatio: j.FailureRatio,
ConsecutiveFailures: j.ConsecutiveFailures,
}
}
type jsonWorker struct {
Concurrency int `json:"concurrency"`
MaxBatchSize int `json:"maxBatchSize"`
ProcessTimeout Duration `json:"processTimeout"`
ShutdownTimeout Duration `json:"shutdownTimeout"`
BackoffMin Duration `json:"backoffMin"`
BackoffMax Duration `json:"backoffMax"`
PermanentFailureDelay Duration `json:"permanentFailureDelay"`
MonitorAddr string `json:"monitorAddr"`
HealthErrorThreshold int `json:"healthErrorThreshold"`
DepthPollInterval Duration `json:"depthPollInterval"`
}
type jsonOutbox struct {
Enabled bool `json:"enabled"`
PollInterval Duration `json:"pollInterval"`
BatchSize int `json:"batchSize"`
DispatchTimeout Duration `json:"dispatchTimeout"`
LockTTL Duration `json:"lockTTL"`
MaxAttempts int `json:"maxAttempts"`
}
type jsonSQS struct {
Region string `json:"region"`
Endpoint string `json:"endpoint"`
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
QueueURL string `json:"queueUrl"`
QueueType string `json:"queueType"`
MessageGroupID string `json:"messageGroupId"`
MaxMessages int `json:"maxMessages"`
WaitTimeSeconds int32 `json:"waitTimeSeconds"`
VisibilityTimeout Duration `json:"visibilityTimeout"`
}
func (j jsonSQS) toSQSConfig(regionFallback, endpointFallback, accessKeyID, secretAccessKey string) SQSConfig {
region := j.Region
if region == "" {
region = regionFallback
}
endpoint := j.Endpoint
if endpoint == "" {
endpoint = endpointFallback
}
return SQSConfig{
Region: region,
AccessKeyID: strings.TrimSpace(accessKeyID),
SecretAccessKey: strings.TrimSpace(secretAccessKey),
Endpoint: endpoint,
QueueURL: j.QueueURL,
QueueType: j.QueueType,
MessageGroupID: j.MessageGroupID,
MaxMessages: j.MaxMessages,
WaitTimeSeconds: j.WaitTimeSeconds,
VisibilityTimeout: j.VisibilityTimeout.Duration,
AllowInsecureEndpoint: j.AllowInsecureEndpoint,
}
}
type jsonAppConfig struct {
HTTP struct {
Port int `json:"port"`
BodyLimit int `json:"bodyLimit"`
Timeouts struct {
Read Duration `json:"read"`
Write Duration `json:"write"`
Idle Duration `json:"idle"`
Request Duration `json:"request"`
RequestOverrides string `json:"requestOverrides"`
Shutdown Duration `json:"shutdown"`
} `json:"timeouts"`
CORS struct {
AllowOrigins string `json:"allowOrigins"`
AllowMethods string `json:"allowMethods"`
AllowHeaders string `json:"allowHeaders"`
ExposeHeaders string `json:"exposeHeaders"`
AllowCredentials bool `json:"allowCredentials"`
MaxAge int `json:"maxAge"`
} `json:"cors"`
RateLimit struct {
Max int `json:"max"`
Window Duration `json:"window"`
Endpoints string `json:"endpoints"`
} `json:"rateLimit"`
Security struct {
HSTSMaxAge int `json:"hstsMaxAge"`
} `json:"security"`
} `json:"http"`
Database struct {
MySQL struct {
DSN string `json:"dsn"`
MaxOpenConns int `json:"maxOpenConns"`
MaxIdleConns int `json:"maxIdleConns"`
ConnMaxLifetime Duration `json:"connMaxLifetime"`
ConnMaxIdleTime Duration `json:"connMaxIdleTime"`
} `json:"mysql"`
} `json:"database"`
Logging struct {
Level string `json:"level"`
Format string `json:"format"`
} `json:"logging"`
Audit struct {
QueueSize int `json:"queueSize"`
Workers int `json:"workers"`
} `json:"audit"`
Email struct {
Provider string `json:"provider"`
MockDir string `json:"mockDir"`
} `json:"email"`
CircuitBreaker struct {
Default jsonCircuitBreakerPolicy `json:"default"`
MicrosoftSSO jsonCircuitBreakerPolicy `json:"microsoftSSO"`
SES jsonCircuitBreakerPolicy `json:"ses"`
SQS jsonCircuitBreakerPolicy `json:"sqs"`
} `json:"circuitBreaker"`
Auth struct {
PublicBaseURL string `json:"publicBaseURL"`
PasswordPepper string `json:"passwordPepper"`
IPEncryptionKey string `json:"ipEncryptionKey"`
DefaultRole string `json:"defaultRole"`
DisableRegister bool `json:"disableRegister"`
Hash struct {
Time uint32 `json:"time"`
MemoryKB uint32 `json:"memoryKB"`
Threads uint8 `json:"threads"`
KeyLen uint32 `json:"keyLen"`
} `json:"hash"`
Email struct {
VerifyTTL Duration `json:"verifyTTL"`
VerifyURL string `json:"verifyURL"`
InviteTTL Duration `json:"inviteTTL"`
} `json:"email"`
Password struct {
SetURL string `json:"setURL"`
ResetTTL Duration `json:"resetTTL"`
ResetURL string `json:"resetURL"`
ForgotEmailCooldown Duration `json:"forgotEmailCooldown"`
ForgotMinDuration Duration `json:"forgotMinDuration"`
ForgotIPRateMax int `json:"forgotIPRateMax"`
ForgotIPRateWindow Duration `json:"forgotIPRateWindow"`
LoginIPRateMax int `json:"loginIPRateMax"`
LoginIPRateWindow Duration `json:"loginIPRateWindow"`
} `json:"password"`
SecurityPin struct {
ResetTTL Duration `json:"resetTTL"`
ResetURL string `json:"resetURL"`
ForgotEmailCooldown Duration `json:"forgotEmailCooldown"`
ForgotMinDuration Duration `json:"forgotMinDuration"`
MaxAttempts int `json:"maxAttempts"`
LockDuration Duration `json:"lockDuration"`
ActionTokenTTL Duration `json:"actionTokenTTL"`
} `json:"securityPin"`
JWT struct {
AccessSecret string `json:"accessSecret"`
RefreshSecret string `json:"refreshSecret"`
AccessTTL Duration `json:"accessTTL"`
RefreshTTL Duration `json:"refreshTTL"`
RefreshTotpTTL Duration `json:"refreshTotpTTL"`
LocalRefreshTTL Duration `json:"localRefreshTTL"`
SessionIdleTTL Duration `json:"sessionIdleTTL"`
Cookie struct {
Domain string `json:"domain"`
Secure bool `json:"secure"`
SameSite string `json:"sameSite"`
AccessName string `json:"accessName"`
RefreshName string `json:"refreshName"`
} `json:"cookie"`
} `json:"jwt"`
TOTP struct {
ChallengeTTL Duration `json:"challengeTTL"`
} `json:"totp"`
SSO struct {
StateTTL Duration `json:"stateTTL"`
SuccessRedirect string `json:"successRedirect"`
} `json:"sso"`
WebAuthn struct {
RPID string `json:"rpId"`
RPDisplayName string `json:"rpDisplayName"`
RPOrigins string `json:"rpOrigins"`
ChallengeTTL Duration `json:"challengeTTL"`
} `json:"webauthn"`
} `json:"auth"`
TOTP struct {
Issuer string `json:"issuer"`
SecretKey string `json:"secretKey"`
} `json:"totp"`
MicrosoftSSO struct {
TenantID string `json:"tenantId"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
RedirectURL string `json:"redirectUrl"`
Authority string `json:"authority"`
Scopes string `json:"scopes"`
} `json:"microsoftSSO"`
SAML struct {
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
MetadataFile string `json:"metadataFile"`
RootURL string `json:"rootUrl"`
FrontendURL string `json:"frontendUrl"`
CookieDomain string `json:"cookieDomain"`
CookieSecure bool `json:"cookieSecure"`
SessionTTLHr int `json:"sessionTTLHour"`
} `json:"saml"`
Queue struct {
MessageSchemaVersion string `json:"messageSchemaVersion"`
EnableLegacyPayloadFallback bool `json:"enableLegacyPayloadFallback"`
IdempotencyKeyPrefix string `json:"idempotencyKeyPrefix"`
IdempotencyTTL Duration `json:"idempotencyTTL"`
ProcessingTTL Duration `json:"processingTTL"`
Worker jsonWorker `json:"worker"`
Outbox jsonOutbox `json:"outbox"`
} `json:"queue"`
FileQueue struct {
MessageSchemaVersion string `json:"messageSchemaVersion"`
IdempotencyKeyPrefix string `json:"idempotencyKeyPrefix"`
IdempotencyTTL Duration `json:"idempotencyTTL"`
ProcessingTTL Duration `json:"processingTTL"`
Worker jsonWorker `json:"worker"`
Outbox jsonOutbox `json:"outbox"`
} `json:"fileQueue"`
AWS struct {
Region string `json:"region"`
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
SQS jsonSQS `json:"sqs"`
FileSQS jsonSQS `json:"fileSqs"`
SES struct {
Region string `json:"region"`
Endpoint string `json:"endpoint"`
From string `json:"from"`
ConfigurationSet string `json:"configurationSet"`
SendTimeout Duration `json:"sendTimeout"`
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
} `json:"ses"`
} `json:"aws"`
File struct {
Provider string `json:"provider"`
S3 struct {
Bucket string `json:"bucket"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
ForcePathStyle bool `json:"forcePathStyle"`
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
AutoCreateBucket bool `json:"autoCreateBucket"`
ObjectKeyPrefix string `json:"objectKeyPrefix"`
PresignPutTTL Duration `json:"presignPutTTL"`
PresignGetTTL Duration `json:"presignGetTTL"`
} `json:"s3"`
Upload struct {
MaxSize int64 `json:"maxSize"`
AllowedMimeTypes string `json:"allowedMimeTypes"`
AllowedExtensions string `json:"allowedExtensions"`
AllowEmptyFile bool `json:"allowEmptyFile"`
MaxFilenameLength int `json:"maxFilenameLength"`
} `json:"upload"`
} `json:"file"`
WOPI struct {
TokenSecret string `json:"tokenSecret"`
ProofSecret string `json:"proofSecret"`
DiscoveryURL string `json:"discoveryUrl"`
PublicBaseURL string `json:"publicBaseUrl"`
EditActionURL string `json:"editActionUrl"`
TokenTTL Duration `json:"tokenTTL"`
MaxBodyBytes int `json:"maxBodyBytes"`
RequireProof bool `json:"requireProof"`
} `json:"wopi"`
Gotenberg struct {
URL string `json:"url"`
} `json:"gotenberg"`
}
func (r *jsonAppConfig) toAppConfig() AppConfig {
// IPEncryptionKey falls back to PasswordPepper when not set (mirrors env loader behaviour)
ipKey := r.Auth.IPEncryptionKey
if ipKey == "" {
ipKey = r.Auth.PasswordPepper
}
// RefreshTotpTTL falls back to RefreshTTL when not set
refreshTotpTTL := r.Auth.JWT.RefreshTotpTTL.Duration
if refreshTotpTTL == 0 {
refreshTotpTTL = r.Auth.JWT.RefreshTTL.Duration
}
// SES region falls back to aws.region
sesRegion := r.AWS.SES.Region
if sesRegion == "" {
sesRegion = r.AWS.Region
}
return AppConfig{
HTTPPort: r.HTTP.Port,
MySQL: MySQLConfig{
DSN: r.Database.MySQL.DSN,
MaxOpenConns: r.Database.MySQL.MaxOpenConns,
MaxIdleConns: r.Database.MySQL.MaxIdleConns,
ConnMaxLifetime: r.Database.MySQL.ConnMaxLifetime.Duration,
ConnMaxIdleTime: r.Database.MySQL.ConnMaxIdleTime.Duration,
},
Logging: LoggingConfig{
Level: r.Logging.Level,
Format: r.Logging.Format,
},
Audit: AuditConfig{
QueueSize: r.Audit.QueueSize,
Workers: r.Audit.Workers,
},
Email: EmailDeliveryConfig{
Provider: strings.ToLower(strings.TrimSpace(r.Email.Provider)),
MockDir: strings.TrimSpace(r.Email.MockDir),
},
SES: SESConfig{
Region: sesRegion,
Endpoint: r.AWS.SES.Endpoint,
From: r.AWS.SES.From,
ConfigurationSetName: r.AWS.SES.ConfigurationSet,
SendTimeout: r.AWS.SES.SendTimeout.Duration,
AllowInsecureEndpoint: r.AWS.SES.AllowInsecureEndpoint,
},
Auth: AuthConfig{
PublicBaseURL: r.Auth.PublicBaseURL,
PasswordPepper: r.Auth.PasswordPepper,
IPEncryptionKey: ipKey,
HashTime: r.Auth.Hash.Time,
HashMemoryKB: r.Auth.Hash.MemoryKB,
HashThreads: r.Auth.Hash.Threads,
HashKeyLen: r.Auth.Hash.KeyLen,
EmailVerifyTTL: r.Auth.Email.VerifyTTL.Duration,
EmailVerifyURL: r.Auth.Email.VerifyURL,
InviteTTL: r.Auth.Email.InviteTTL.Duration,
SetPasswordURL: r.Auth.Password.SetURL,
PasswordResetTTL: r.Auth.Password.ResetTTL.Duration,
PasswordResetURL: r.Auth.Password.ResetURL,
SecurityPINResetTTL: r.Auth.SecurityPin.ResetTTL.Duration,
SecurityPINResetURL: r.Auth.SecurityPin.ResetURL,
ForgotSecurityPINCooldown: r.Auth.SecurityPin.ForgotEmailCooldown.Duration,
ForgotSecurityPINMinDuration: r.Auth.SecurityPin.ForgotMinDuration.Duration,
SecurityPINMaxAttempts: r.Auth.SecurityPin.MaxAttempts,
SecurityPINLockDuration: r.Auth.SecurityPin.LockDuration.Duration,
SecurityPINActionTokenTTL: r.Auth.SecurityPin.ActionTokenTTL.Duration,
ForgotPasswordEmailCooldown: r.Auth.Password.ForgotEmailCooldown.Duration,
ForgotPasswordMinDuration: r.Auth.Password.ForgotMinDuration.Duration,
ForgotPasswordIPRateMax: r.Auth.Password.ForgotIPRateMax,
ForgotPasswordIPRateWindow: r.Auth.Password.ForgotIPRateWindow.Duration,
LoginIPRateMax: r.Auth.Password.LoginIPRateMax,
LoginIPRateWindow: r.Auth.Password.LoginIPRateWindow.Duration,
TOTPChallengeTTL: r.Auth.TOTP.ChallengeTTL.Duration,
SSOStateTTL: r.Auth.SSO.StateTTL.Duration,
TOTPIssuer: r.TOTP.Issuer,
TOTPSecretKeyB64: r.TOTP.SecretKey,
JWTAccessSecret: r.Auth.JWT.AccessSecret,
JWTRefreshSecret: r.Auth.JWT.RefreshSecret,
JWTAccessTTL: r.Auth.JWT.AccessTTL.Duration,
JWTRefreshTTL: r.Auth.JWT.RefreshTTL.Duration,
JWTRefreshTOTPTTL: refreshTotpTTL,
JWTLocalRefreshTTL: r.Auth.JWT.LocalRefreshTTL.Duration,
SessionIdleTTL: r.Auth.JWT.SessionIdleTTL.Duration,
JWTCookieDomain: r.Auth.JWT.Cookie.Domain,
JWTCookieSecure: r.Auth.JWT.Cookie.Secure,
JWTCookieSameSite: r.Auth.JWT.Cookie.SameSite,
JWTAccessCookieName: r.Auth.JWT.Cookie.AccessName,
JWTRefreshCookieName: r.Auth.JWT.Cookie.RefreshName,
WebAuthnRPID: r.Auth.WebAuthn.RPID,
WebAuthnRPDisplayName: r.Auth.WebAuthn.RPDisplayName,
WebAuthnRPOrigins: splitCSV(r.Auth.WebAuthn.RPOrigins),
WebAuthnChallengeTTL: r.Auth.WebAuthn.ChallengeTTL.Duration,
SSOSuccessRedirectURL: r.Auth.SSO.SuccessRedirect,
DefaultRoleName: r.Auth.DefaultRole,
DisableRegister: r.Auth.DisableRegister,
},
MicrosoftSSO: MicrosoftSSOConfig{
TenantID: r.MicrosoftSSO.TenantID,
ClientID: r.MicrosoftSSO.ClientID,
ClientSecret: r.MicrosoftSSO.ClientSecret,
RedirectURL: r.MicrosoftSSO.RedirectURL,
Authority: r.MicrosoftSSO.Authority,
Scopes: splitCSV(r.MicrosoftSSO.Scopes),
},
SAML: SAMLConfig{
CertFile: strings.TrimSpace(r.SAML.CertFile),
KeyFile: strings.TrimSpace(r.SAML.KeyFile),
MetadataFile: strings.TrimSpace(r.SAML.MetadataFile),
RootURL: strings.TrimSpace(r.SAML.RootURL),
FrontendURL: strings.TrimSpace(r.SAML.FrontendURL),
CookieDomain: strings.TrimSpace(r.SAML.CookieDomain),
CookieSecure: r.SAML.CookieSecure,
SessionTTLHr: r.SAML.SessionTTLHr,
},
HTTP: HTTPConfig{
BodyLimit: r.HTTP.BodyLimit,
ReadTimeout: r.HTTP.Timeouts.Read.Duration,
WriteTimeout: r.HTTP.Timeouts.Write.Duration,
IdleTimeout: r.HTTP.Timeouts.Idle.Duration,
RequestTimeout: r.HTTP.Timeouts.Request.Duration,
RequestTimeoutByPath: parseHTTPTimeoutOverrides(r.HTTP.Timeouts.RequestOverrides),
ShutdownTimeout: r.HTTP.Timeouts.Shutdown.Duration,
CORSAllowOrigins: r.HTTP.CORS.AllowOrigins,
CORSAllowMethods: r.HTTP.CORS.AllowMethods,
CORSAllowHeaders: r.HTTP.CORS.AllowHeaders,
CORSExposeHeaders: r.HTTP.CORS.ExposeHeaders,
CORSAllowCredentials: r.HTTP.CORS.AllowCredentials,
CORSMaxAge: r.HTTP.CORS.MaxAge,
RateLimitMax: r.HTTP.RateLimit.Max,
RateLimitWindow: r.HTTP.RateLimit.Window.Duration,
RateLimitByEndpoint: parseHTTPEndpointRateLimitOverrides(r.HTTP.RateLimit.Endpoints),
HSTSMaxAge: r.HTTP.Security.HSTSMaxAge,
},
Resilience: ResilienceConfig{
Default: r.CircuitBreaker.Default.toPolicy(),
MicrosoftSSO: r.CircuitBreaker.MicrosoftSSO.toPolicy(),
SES: r.CircuitBreaker.SES.toPolicy(),
SQS: r.CircuitBreaker.SQS.toPolicy(),
},
Queue: QueueConfig{
MessageSchemaVersion: r.Queue.MessageSchemaVersion,
EnableLegacyPayloadFallback: r.Queue.EnableLegacyPayloadFallback,
IdempotencyKeyPrefix: r.Queue.IdempotencyKeyPrefix,
IdempotencyTTL: r.Queue.IdempotencyTTL.Duration,
ProcessingTTL: r.Queue.ProcessingTTL.Duration,
WorkerConcurrency: r.Queue.Worker.Concurrency,
WorkerMaxBatchSize: r.Queue.Worker.MaxBatchSize,
WorkerProcessTimeout: r.Queue.Worker.ProcessTimeout.Duration,
WorkerShutdownTimeout: r.Queue.Worker.ShutdownTimeout.Duration,
WorkerBackoffMin: r.Queue.Worker.BackoffMin.Duration,
WorkerBackoffMax: r.Queue.Worker.BackoffMax.Duration,
WorkerPermanentFailureDelay: r.Queue.Worker.PermanentFailureDelay.Duration,
WorkerMonitorAddr: r.Queue.Worker.MonitorAddr,
WorkerHealthErrorThreshold: r.Queue.Worker.HealthErrorThreshold,
WorkerDepthPollInterval: r.Queue.Worker.DepthPollInterval.Duration,
OutboxEnabled: r.Queue.Outbox.Enabled,
OutboxPollInterval: r.Queue.Outbox.PollInterval.Duration,
OutboxBatchSize: r.Queue.Outbox.BatchSize,
OutboxDispatchTimeout: r.Queue.Outbox.DispatchTimeout.Duration,
OutboxLockTTL: r.Queue.Outbox.LockTTL.Duration,
OutboxMaxAttempts: r.Queue.Outbox.MaxAttempts,
},
FileQueue: FileQueueConfig{
MessageSchemaVersion: r.FileQueue.MessageSchemaVersion,
IdempotencyKeyPrefix: r.FileQueue.IdempotencyKeyPrefix,
IdempotencyTTL: r.FileQueue.IdempotencyTTL.Duration,
ProcessingTTL: r.FileQueue.ProcessingTTL.Duration,
WorkerConcurrency: r.FileQueue.Worker.Concurrency,
WorkerMaxBatchSize: r.FileQueue.Worker.MaxBatchSize,
WorkerProcessTimeout: r.FileQueue.Worker.ProcessTimeout.Duration,
WorkerShutdownTimeout: r.FileQueue.Worker.ShutdownTimeout.Duration,
WorkerBackoffMin: r.FileQueue.Worker.BackoffMin.Duration,
WorkerBackoffMax: r.FileQueue.Worker.BackoffMax.Duration,
WorkerPermanentFailureDelay: r.FileQueue.Worker.PermanentFailureDelay.Duration,
WorkerMonitorAddr: r.FileQueue.Worker.MonitorAddr,
WorkerHealthErrorThreshold: r.FileQueue.Worker.HealthErrorThreshold,
WorkerDepthPollInterval: r.FileQueue.Worker.DepthPollInterval.Duration,
OutboxEnabled: r.FileQueue.Outbox.Enabled,
OutboxPollInterval: r.FileQueue.Outbox.PollInterval.Duration,
OutboxBatchSize: r.FileQueue.Outbox.BatchSize,
OutboxDispatchTimeout: r.FileQueue.Outbox.DispatchTimeout.Duration,
OutboxLockTTL: r.FileQueue.Outbox.LockTTL.Duration,
OutboxMaxAttempts: r.FileQueue.Outbox.MaxAttempts,
},
// SQS email queue: region falls back to aws.region
SQS: r.AWS.SQS.toSQSConfig(r.AWS.Region, "", r.AWS.AccessKeyID, r.AWS.SecretAccessKey),
// FileSQS: region + endpoint fall back to aws.region / aws.sqs.endpoint
FileSQS: r.AWS.FileSQS.toSQSConfig(r.AWS.Region, r.AWS.SQS.Endpoint, r.AWS.AccessKeyID, r.AWS.SecretAccessKey),
FileStorage: FileStorageConfig{
Provider: strings.ToLower(strings.TrimSpace(r.File.Provider)),
Bucket: strings.TrimSpace(r.File.S3.Bucket),
Region: strings.TrimSpace(r.File.S3.Region),
Endpoint: strings.TrimSpace(r.File.S3.Endpoint),
AccessKeyID: strings.TrimSpace(r.AWS.AccessKeyID),
SecretAccessKey: strings.TrimSpace(r.AWS.SecretAccessKey),
ForcePathStyle: r.File.S3.ForcePathStyle,
AllowInsecureEndpoint: r.File.S3.AllowInsecureEndpoint,
AutoCreateBucket: r.File.S3.AutoCreateBucket,
ObjectKeyPrefix: strings.TrimSpace(r.File.S3.ObjectKeyPrefix),
PresignPutTTL: r.File.S3.PresignPutTTL.Duration,
PresignGetTTL: r.File.S3.PresignGetTTL.Duration,
},
FileUpload: FileManagerUploadPolicyConfig{
MaxFileSizeBytes: r.File.Upload.MaxSize,
AllowedMimeTypes: normalizeMimeTypes(splitCSV(r.File.Upload.AllowedMimeTypes)),
AllowedExtensions: normalizeExtensions(splitCSV(r.File.Upload.AllowedExtensions)),
AllowEmptyFile: r.File.Upload.AllowEmptyFile,
MaxFilenameLength: r.File.Upload.MaxFilenameLength,
},
WOPI: WOPIConfig{
TokenSecret: strings.TrimSpace(r.WOPI.TokenSecret),
ProofSecret: strings.TrimSpace(r.WOPI.ProofSecret),
DiscoveryURL: strings.TrimSpace(r.WOPI.DiscoveryURL),
PublicBaseURL: strings.TrimSpace(r.WOPI.PublicBaseURL),
EditActionURL: strings.TrimSpace(r.WOPI.EditActionURL),
TokenTTL: r.WOPI.TokenTTL.Duration,
MaxBodyBytes: r.WOPI.MaxBodyBytes,
RequireProof: r.WOPI.RequireProof,
},
Gotenberg: GotenbergConfig{
URL: strings.TrimSpace(r.Gotenberg.URL),
},
}
}
func splitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}