// 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"` Admin AdminConfig `yaml:"admin"` 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"` Media MediaConfig `yaml:"media"` SquareBot SquareBotConfig `yaml:"square_bot"` SMS SMSConfig `yaml:"sms"` } // AdminConfig is popadmin JWT validation (separate from client jwt). type AdminConfig struct { JWTSecret string `yaml:"jwt_secret"` } 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"` PublicEndpoint string `yaml:"public_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"` } // MediaConfig is server-side media detect (users) + compress (bots / objectstore). // Image/avatar hard cap defaults to 150 KiB; see docs/media.md. type MediaConfig struct { Enabled bool `yaml:"enabled"` UserReject bool `yaml:"user_reject"` // false=log only; true=return 40005 BotCompress bool `yaml:"bot_compress"` ImageMaxEdge int `yaml:"image_max_edge"` VideoMaxEdge int `yaml:"video_max_edge"` AvatarMaxEdge int `yaml:"avatar_max_edge"` ImageMaxBytes int64 `yaml:"image_max_bytes"` VideoMaxBytes int64 `yaml:"video_max_bytes"` AvatarMaxBytes int64 `yaml:"avatar_max_bytes"` JPEGQuality int `yaml:"jpeg_quality"` VideoCRF int `yaml:"video_crf"` FFmpegPath string `yaml:"ffmpeg_path"` } // SquareBotConfig drives the overseas media RSS publisher on the square feed. type SquareBotConfig struct { Enabled bool `yaml:"enabled"` Interval time.Duration `yaml:"interval"` TTL time.Duration `yaml:"ttl"` Feeds []string `yaml:"feeds"` BotIndexes []int `yaml:"bot_indexes"` } type MessageConfig struct { MaxContentSize int `yaml:"max_content_size"` SyncLimit int `yaml:"sync_limit"` MsgIDExpireMS int64 `yaml:"msgid_expire_ms"` } type SMSConfig struct { SMTP SMTPConfig `yaml:"smtp"` AliyunSMS AliyunSMSConfig `yaml:"aliyun_sms"` } type SMTPConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` Username string `yaml:"username"` Password string `yaml:"password"` From string `yaml:"from"` } type AliyunSMSConfig struct { AccessKeyID string `yaml:"access_key_id"` AccessKeySecret string `yaml:"access_key_secret"` SignName string `yaml:"sign_name"` Templates AliyunSMSTemplatesConfig `yaml:"templates"` } type AliyunSMSTemplatesConfig struct { Login AliyunCarrierTemplates `yaml:"login"` AccountDelete AliyunCarrierTemplates `yaml:"account_delete"` } type AliyunCarrierTemplates struct { Mobile string `yaml:"mobile"` Unicom string `yaml:"unicom"` Telecom string `yaml:"telecom"` } 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"` PairKeyMode string `yaml:"pair_key_mode"` } 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) { applyConfigEnvOverrides(cfg) applyConfigStructDefaults(cfg) } func applyMediaDefaults(m *MediaConfig) { if m == nil { return } // Numeric/path defaults only. Toggle via YAML media.enabled / media.bot_compress // (configs ship with enabled: true). Explicit enabled: false stays off. if m.ImageMaxEdge <= 0 { m.ImageMaxEdge = 1080 } if m.VideoMaxEdge <= 0 { m.VideoMaxEdge = 720 } if m.AvatarMaxEdge <= 0 { m.AvatarMaxEdge = 200 } if m.ImageMaxBytes <= 0 { m.ImageMaxBytes = 150 * 1024 // 150 KiB } if m.VideoMaxBytes <= 0 { m.VideoMaxBytes = 100 << 20 } if m.AvatarMaxBytes <= 0 { m.AvatarMaxBytes = 150 * 1024 // 150 KiB } if m.JPEGQuality <= 0 { m.JPEGQuality = 85 } if m.VideoCRF <= 0 { m.VideoCRF = 23 } if strings.TrimSpace(m.FFmpegPath) == "" { m.FFmpegPath = "ffmpeg" } } 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 strings.TrimSpace(cfg.Admin.JWTSecret) == "" || strings.TrimSpace(cfg.Admin.JWTSecret) == "wyrs-admin-jwt-secret-change-in-production-2025" { return fmt.Errorf("prod: admin.jwt_secret must be set via config or SPARK_ADMIN_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") } } if strings.TrimSpace(cfg.SMS.SMTP.Host) == "" { return fmt.Errorf("prod: sms.smtp.host is required") } if strings.TrimSpace(cfg.SMS.SMTP.Username) == "" || strings.TrimSpace(cfg.SMS.SMTP.Password) == "" { return fmt.Errorf("prod: sms.smtp.username and password are required") } return nil }