Initial commit

This commit is contained in:
wangxuming
2026-07-12 15:53:24 +08:00
commit 68d61700d5
252 changed files with 23291 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
package config
import (
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Environment string
HTTPAddr string
DatabaseURL string
EncryptionKey []byte
PhoneHMACKey []byte
SessionCookieName string
SessionSecure bool
SessionTTL time.Duration
MigrateOnStart bool
ShutdownTimeout time.Duration
DBMaxOpenConns int
DBMaxIdleConns int
DBConnMaxIdleTime time.Duration
DBConnMaxLifetime time.Duration
MaintenanceInterval time.Duration
MaintenanceBatchSize int
}
func Load() (Config, error) {
return load(os.LookupEnv)
}
func load(lookup func(string) (string, bool)) (Config, error) {
get := func(name, fallback string) string {
if value, ok := lookup(name); ok {
return strings.TrimSpace(value)
}
return fallback
}
encryptionKey, err := decodeKey("DATA_ENCRYPTION_KEY_BASE64", get("DATA_ENCRYPTION_KEY_BASE64", ""))
if err != nil {
return Config{}, err
}
phoneHMACKey, err := decodeKey("PHONE_HMAC_KEY_BASE64", get("PHONE_HMAC_KEY_BASE64", ""))
if err != nil {
return Config{}, err
}
sessionTTL, err := time.ParseDuration(get("SESSION_TTL", "12h"))
if err != nil || sessionTTL < 15*time.Minute || sessionTTL > 7*24*time.Hour {
return Config{}, errors.New("SESSION_TTL must be a duration between 15m and 168h")
}
shutdownTimeout, err := time.ParseDuration(get("SHUTDOWN_TIMEOUT", "10s"))
if err != nil || shutdownTimeout < time.Second || shutdownTimeout > time.Minute {
return Config{}, errors.New("SHUTDOWN_TIMEOUT must be a duration between 1s and 1m")
}
sessionSecure, err := strconv.ParseBool(get("SESSION_COOKIE_SECURE", "true"))
if err != nil {
return Config{}, fmt.Errorf("SESSION_COOKIE_SECURE: %w", err)
}
migrate, err := strconv.ParseBool(get("MIGRATE_ON_START", "true"))
if err != nil {
return Config{}, fmt.Errorf("MIGRATE_ON_START: %w", err)
}
environment := get("APP_ENV", "development")
databaseURL := get("DATABASE_URL", "")
if databaseURL == "" {
return Config{}, errors.New("DATABASE_URL is required")
}
maxOpen, err := parseIntSetting("DB_MAX_OPEN_CONNS", get("DB_MAX_OPEN_CONNS", "20"), 1, 1000)
if err != nil {
return Config{}, err
}
maxIdle, err := parseIntSetting("DB_MAX_IDLE_CONNS", get("DB_MAX_IDLE_CONNS", "5"), 0, 1000)
if err != nil {
return Config{}, err
}
if maxIdle > maxOpen {
return Config{}, errors.New("DB_MAX_IDLE_CONNS cannot exceed DB_MAX_OPEN_CONNS")
}
connMaxIdleTime, err := parseDurationSetting("DB_CONN_MAX_IDLE_TIME", get("DB_CONN_MAX_IDLE_TIME", "5m"), time.Minute, 24*time.Hour)
if err != nil {
return Config{}, err
}
connMaxLifetime, err := parseDurationSetting("DB_CONN_MAX_LIFETIME", get("DB_CONN_MAX_LIFETIME", "30m"), time.Minute, 7*24*time.Hour)
if err != nil {
return Config{}, err
}
maintenanceInterval, err := parseDurationSetting("MAINTENANCE_INTERVAL", get("MAINTENANCE_INTERVAL", "1h"), 0, 24*time.Hour)
if err != nil {
return Config{}, err
}
maintenanceBatchSize, err := parseIntSetting("MAINTENANCE_BATCH_SIZE", get("MAINTENANCE_BATCH_SIZE", "500"), 1, 10000)
if err != nil {
return Config{}, err
}
if environment == "production" && !sessionSecure {
return Config{}, errors.New("SESSION_COOKIE_SECURE must be true in production")
}
if environment == "production" && migrate {
return Config{}, errors.New("MIGRATE_ON_START must be false in production; run migrations as a release job")
}
if environment == "production" && !postgresTLSConfigured(databaseURL) {
return Config{}, errors.New("DATABASE_URL must use PostgreSQL TLS in production")
}
if environment == "production" && maintenanceInterval <= 0 {
return Config{}, errors.New("MAINTENANCE_INTERVAL must be positive in production")
}
cookieName := get("SESSION_COOKIE_NAME", "queue_session")
if cookieName == "" || strings.ContainsAny(cookieName, "()<>@,;:\\\"/[]?={} \t") {
return Config{}, errors.New("SESSION_COOKIE_NAME is not a valid cookie name")
}
return Config{
Environment: environment,
HTTPAddr: get("HTTP_ADDR", ":8080"),
DatabaseURL: databaseURL,
EncryptionKey: encryptionKey,
PhoneHMACKey: phoneHMACKey,
SessionCookieName: cookieName,
SessionSecure: sessionSecure,
SessionTTL: sessionTTL,
MigrateOnStart: migrate,
ShutdownTimeout: shutdownTimeout,
DBMaxOpenConns: maxOpen,
DBMaxIdleConns: maxIdle,
DBConnMaxIdleTime: connMaxIdleTime,
DBConnMaxLifetime: connMaxLifetime,
MaintenanceInterval: maintenanceInterval,
MaintenanceBatchSize: maintenanceBatchSize,
}, nil
}
func parseIntSetting(name, value string, min, max int) (int, error) {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed < min || parsed > max {
return 0, fmt.Errorf("%s must be an integer between %d and %d", name, min, max)
}
return parsed, nil
}
func parseDurationSetting(name, value string, min, max time.Duration) (time.Duration, error) {
parsed, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil || parsed < min || parsed > max {
return 0, fmt.Errorf("%s must be a duration between %s and %s", name, min, max)
}
return parsed, nil
}
func postgresTLSConfigured(dsn string) bool {
value := strings.TrimSpace(dsn)
if parsed, err := url.Parse(value); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
return secureSSLMode(parsed.Query().Get("sslmode"))
}
for _, field := range strings.Fields(value) {
if strings.HasPrefix(field, "sslmode=") {
return secureSSLMode(strings.TrimPrefix(field, "sslmode="))
}
}
return false
}
func secureSSLMode(mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "require", "verify-ca", "verify-full":
return true
default:
return false
}
}
func decodeKey(name, value string) ([]byte, error) {
if value == "" {
return nil, fmt.Errorf("%s is required", name)
}
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return nil, fmt.Errorf("%s must be standard base64: %w", name, err)
}
if len(decoded) != 32 {
return nil, fmt.Errorf("%s must decode to exactly 32 bytes", name)
}
return decoded, nil
}