// Package bootstrap loads config and owns process lifecycle. package bootstrap import ( "fmt" "os" "strings" "time" "gopkg.in/yaml.v3" ) type Config struct { Env string `yaml:"env"` Server ServerConfig `yaml:"server"` Log LogConfig `yaml:"log"` ID IDConfig `yaml:"id"` MySQL MySQLConfig `yaml:"mysql"` Redis RedisConfig `yaml:"redis"` JWT JWTConfig `yaml:"jwt"` TaskPool TaskPoolConfig `yaml:"taskpool"` Deliver DeliverConfig `yaml:"deliver"` Session SessionConfig `yaml:"session"` RateLimit RateLimitConfig `yaml:"rate_limit"` Message MessageConfig `yaml:"message"` ObjectStorage ObjectStorageConfig `yaml:"object_storage"` } type LogConfig struct { Level string `yaml:"level"` Encoding string `yaml:"encoding"` Console bool `yaml:"console"` File LogFileConfig `yaml:"file"` } type LogFileConfig struct { Path string `yaml:"path"` MaxSizeMB int `yaml:"max_size_mb"` MaxBackups int `yaml:"max_backups"` MaxAgeDays int `yaml:"max_age_days"` Compress bool `yaml:"compress"` } type ServerConfig struct { HTTP HTTPConfig `yaml:"http"` TCP TCPConfig `yaml:"tcp"` } type HTTPConfig struct { Addr string `yaml:"addr"` } type TCPConfig struct { Addr string `yaml:"addr"` ProxyProtocol bool `yaml:"proxy_protocol"` TLS TLSConfig `yaml:"tls"` MaxFrameSize int `yaml:"max_frame_size"` IdleTimeout time.Duration `yaml:"idle_timeout"` KeepAlive time.Duration `yaml:"keepalive"` RecvBuffer int `yaml:"recv_buffer"` SendBuffer int `yaml:"send_buffer"` Multicore bool `yaml:"multicore"` CompressMinSize int `yaml:"compress_min_size"` } type TLSConfig struct { CertFile string `yaml:"cert_file"` KeyFile string `yaml:"key_file"` } type IDConfig struct { SnowflakeWorkerID int64 `yaml:"snowflake_worker_id"` } type MySQLConfig struct { DSN string `yaml:"dsn"` SlowThreshold time.Duration `yaml:"slow_threshold"` LogLevel string `yaml:"log_level"` MaxOpenConns int `yaml:"max_open_conns"` MaxIdleConns int `yaml:"max_idle_conns"` ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"` ConnMaxIdleTime time.Duration `yaml:"conn_max_idle_time"` } type RedisConfig struct { Addr string `yaml:"addr"` Username string `yaml:"username"` Password string `yaml:"password"` DB int `yaml:"db"` DeliverQueueKey string `yaml:"deliver_queue_key"` DeliverProcessingKey string `yaml:"deliver_processing_key"` DeliverDonePrefix string `yaml:"deliver_done_prefix"` } type TaskPoolConfig struct { Size int `yaml:"size"` Queue int `yaml:"queue"` } type DeliverConfig struct { Workers int `yaml:"workers"` TakeTimeout time.Duration `yaml:"take_timeout"` DoneTTL time.Duration `yaml:"done_ttl"` } type SessionConfig struct { OutboundQueueSize int `yaml:"outbound_queue_size"` } type RateLimitConfig struct { MessagePerSecond int `yaml:"message_per_second"` AuthPerSecond int `yaml:"auth_per_second"` SyncInboxPerMinute int `yaml:"sync_inbox_per_minute"` SyncMessagesPerMinute int `yaml:"sync_messages_per_minute"` HistoryPerMinute int `yaml:"history_per_minute"` UploadPerSecond int `yaml:"upload_per_second"` } type ObjectStorageConfig struct { Endpoint string `yaml:"endpoint"` AccessKey string `yaml:"access_key"` SecretKey string `yaml:"secret_key"` Bucket string `yaml:"bucket"` Region string `yaml:"region"` UseSSL bool `yaml:"use_ssl"` PresignTTL time.Duration `yaml:"presign_ttl"` ChatExpireDays int `yaml:"chat_expire_days"` } type MessageConfig struct { MaxContentSize int `yaml:"max_content_size"` SyncLimit int `yaml:"sync_limit"` MsgIDExpireMS int64 `yaml:"msgid_expire_ms"` } type JWTConfig struct { Secret string `yaml:"secret"` Issuer string `yaml:"issuer"` TTL time.Duration `yaml:"ttl"` RefreshTTL time.Duration `yaml:"refresh_ttl"` DenylistPrefix string `yaml:"denylist_prefix"` RefreshPrefix string `yaml:"refresh_prefix"` } func Load(path string) (*Config, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config: %w", err) } cfg := &Config{} if err := yaml.Unmarshal(raw, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } if err := cfg.normalize(); err != nil { return nil, err } return cfg, nil } const ( EnvDev = "dev" EnvProd = "prod" // DefaultJWTSecret is rejected when env is prod. DefaultJWTSecret = "dev-only-change-me" ) func (cfg *Config) Production() bool { return cfg != nil && cfg.Env == EnvProd } func (cfg *Config) normalize() error { if cfg == nil { return fmt.Errorf("config is nil") } applyDefaults(cfg) return cfg.validate() } func applyDefaults(cfg *Config) { if v := os.Getenv("SPARK_ENV"); v != "" { cfg.Env = v } cfg.Env = parseEnv(cfg.Env) if v := os.Getenv("SPARK_JWT_SECRET"); v != "" { cfg.JWT.Secret = v } if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" { cfg.Redis.Password = v } if cfg.JWT.Issuer == "" { cfg.JWT.Issuer = "spark-im" } if cfg.JWT.TTL <= 0 { cfg.JWT.TTL = 7 * 24 * time.Hour } if cfg.JWT.RefreshTTL <= 0 { cfg.JWT.RefreshTTL = 30 * 24 * time.Hour } if cfg.JWT.DenylistPrefix == "" { cfg.JWT.DenylistPrefix = "denylist:" } if cfg.JWT.RefreshPrefix == "" { cfg.JWT.RefreshPrefix = "refresh:" } if cfg.Redis.DeliverQueueKey == "" { cfg.Redis.DeliverQueueKey = "deliver:queue" } if cfg.Redis.DeliverProcessingKey == "" { cfg.Redis.DeliverProcessingKey = "deliver:processing" } if cfg.Redis.DeliverDonePrefix == "" { cfg.Redis.DeliverDonePrefix = "deliver:done:" } if cfg.Deliver.Workers < 1 { cfg.Deliver.Workers = 8 } if cfg.Deliver.TakeTimeout <= 0 { cfg.Deliver.TakeTimeout = 5 * time.Second } if cfg.Deliver.DoneTTL <= 0 { cfg.Deliver.DoneTTL = 10 * time.Minute } if cfg.RateLimit.AuthPerSecond < 1 { cfg.RateLimit.AuthPerSecond = 5 } if cfg.RateLimit.MessagePerSecond < 1 { cfg.RateLimit.MessagePerSecond = 20 } if v := os.Getenv("SPARK_S3_ENDPOINT"); v != "" { cfg.ObjectStorage.Endpoint = v } if v := os.Getenv("SPARK_S3_ACCESS_KEY"); v != "" { cfg.ObjectStorage.AccessKey = v } if v := os.Getenv("SPARK_S3_SECRET_KEY"); v != "" { cfg.ObjectStorage.SecretKey = v } if v := os.Getenv("SPARK_S3_BUCKET"); v != "" { cfg.ObjectStorage.Bucket = v } if cfg.ObjectStorage.Bucket == "" { cfg.ObjectStorage.Bucket = "spark" } if cfg.ObjectStorage.Region == "" { cfg.ObjectStorage.Region = "us-east-1" } if cfg.ObjectStorage.PresignTTL <= 0 { cfg.ObjectStorage.PresignTTL = 15 * time.Minute } if cfg.ObjectStorage.ChatExpireDays < 1 { cfg.ObjectStorage.ChatExpireDays = 365 } if cfg.RateLimit.UploadPerSecond < 1 { cfg.RateLimit.UploadPerSecond = 10 } } func parseEnv(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "prod", "production": return EnvProd default: return EnvDev } } func (cfg *Config) validate() error { if !cfg.Production() { return nil } if strings.TrimSpace(cfg.MySQL.DSN) == "" { return fmt.Errorf("prod: mysql.dsn is required") } if strings.TrimSpace(cfg.Redis.Addr) == "" { return fmt.Errorf("prod: redis.addr is required") } if strings.TrimSpace(cfg.JWT.Secret) == "" || strings.TrimSpace(cfg.JWT.Secret) == DefaultJWTSecret { return fmt.Errorf("prod: jwt.secret must be set via config or SPARK_JWT_SECRET") } if ep := strings.TrimSpace(cfg.ObjectStorage.Endpoint); ep != "" { if strings.TrimSpace(cfg.ObjectStorage.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStorage.SecretKey) == "" { return fmt.Errorf("prod: object_storage access_key and secret_key are required") } } return nil }