Scout/internal/config/config.go
2026-08-31 00:06:06 +08:00

223 lines
7.0 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Environment string
Address string
DataDir string
PublicURL string
AdminEmail string
AdminName string
AdminPassword string
DeveloperToken string
AllowLegacyToken bool
AllowedOrigins []string
AllowedHosts []string
ForceHTTPS bool
TrustProxy bool
SessionDuration time.Duration
MaxUploadBytes int64
RateLimitPerMinute int
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
LogLevel string
}
func Load() (Config, error) {
environment := strings.ToLower(strings.TrimSpace(envOr("SCOUT_ENV", "development")))
if environment == "" {
environment = "development"
}
if environment != "development" && environment != "test" && environment != "production" {
return Config{}, errors.New("SCOUT_ENV 只能是 development、test 或 production")
}
sessionDuration, err := durationEnv("SCOUT_SESSION_DURATION", 24*time.Hour)
if err != nil {
return Config{}, err
}
maxUploadBytes, err := bytesEnv("SCOUT_MAX_UPLOAD_BYTES", 512<<20)
if err != nil {
return Config{}, err
}
rateLimit, err := intEnv("SCOUT_RATE_LIMIT_PER_MINUTE", 120)
if err != nil || rateLimit < 0 {
return Config{}, errors.New("SCOUT_RATE_LIMIT_PER_MINUTE 必须是大于等于 0 的整数")
}
cfg := Config{
Environment: environment,
Address: envOr("SCOUT_ADDR", ":8080"),
DataDir: envOr("SCOUT_DATA_DIR", "data"),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SCOUT_PUBLIC_URL")), "/"),
AdminEmail: strings.TrimSpace(os.Getenv("SCOUT_ADMIN_EMAIL")),
AdminName: strings.TrimSpace(os.Getenv("SCOUT_ADMIN_NAME")),
AdminPassword: os.Getenv("SCOUT_ADMIN_PASSWORD"),
DeveloperToken: os.Getenv("SCOUT_DEVELOPER_TOKEN"),
AllowedOrigins: csvEnv("SCOUT_ALLOWED_ORIGINS"),
AllowedHosts: csvEnv("SCOUT_ALLOWED_HOSTS"),
ForceHTTPS: boolEnv("SCOUT_FORCE_HTTPS", environment == "production"),
TrustProxy: boolEnv("SCOUT_TRUST_PROXY", false),
SessionDuration: sessionDuration,
MaxUploadBytes: maxUploadBytes,
RateLimitPerMinute: rateLimit,
ReadHeaderTimeout: durationDefault("SCOUT_READ_HEADER_TIMEOUT", 10*time.Second),
ReadTimeout: durationDefault("SCOUT_READ_TIMEOUT", 60*time.Second),
WriteTimeout: durationDefault("SCOUT_WRITE_TIMEOUT", 10*time.Minute),
IdleTimeout: durationDefault("SCOUT_IDLE_TIMEOUT", 2*time.Minute),
ShutdownTimeout: durationDefault("SCOUT_SHUTDOWN_TIMEOUT", 15*time.Second),
LogLevel: strings.ToLower(envOr("SCOUT_LOG_LEVEL", "info")),
}
cfg.AllowLegacyToken = boolEnv("SCOUT_ALLOW_LEGACY_TOKEN", environment != "production")
if err := cfg.validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (cfg Config) validate() error {
if strings.TrimSpace(cfg.Address) == "" || strings.TrimSpace(cfg.DataDir) == "" {
return errors.New("SCOUT_ADDR 和 SCOUT_DATA_DIR 不能为空")
}
if cfg.SessionDuration <= 0 || cfg.MaxUploadBytes <= 0 {
return errors.New("会话时长和上传大小必须大于 0")
}
if cfg.LogLevel != "debug" && cfg.LogLevel != "info" && cfg.LogLevel != "warn" && cfg.LogLevel != "error" {
return errors.New("SCOUT_LOG_LEVEL 只能是 debug、info、warn 或 error")
}
if cfg.PublicURL != "" {
parsed, err := url.Parse(cfg.PublicURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("SCOUT_PUBLIC_URL 必须是完整的 HTTP 或 HTTPS 地址")
}
if cfg.Environment == "production" && parsed.Scheme != "https" {
return errors.New("生产环境的 SCOUT_PUBLIC_URL 必须使用 HTTPS")
}
}
if cfg.Environment != "production" {
return nil
}
if cfg.AdminEmail == "" || cfg.AdminPassword == "" {
return errors.New("生产环境必须配置 SCOUT_ADMIN_EMAIL 和 SCOUT_ADMIN_PASSWORD")
}
if cfg.AdminPassword == "Admin-Test-123" {
return errors.New("生产环境不能使用本地测试管理员密码")
}
if cfg.DeveloperToken != "" && !cfg.AllowLegacyToken {
return errors.New("生产环境默认禁止兼容开发者令牌;如确需启用,请设置 SCOUT_ALLOW_LEGACY_TOKEN=true")
}
if len(cfg.AllowedOrigins) == 0 {
return errors.New("生产环境必须配置 SCOUT_ALLOWED_ORIGINS")
}
if len(cfg.AllowedHosts) == 0 {
return errors.New("生产环境必须配置 SCOUT_ALLOWED_HOSTS")
}
if cfg.PublicURL == "" {
return errors.New("生产环境必须配置 SCOUT_PUBLIC_URL")
}
if os.Getenv("SCOUT_SMTP_HOST") == "" || os.Getenv("SCOUT_SMTP_FROM") == "" {
return errors.New("生产环境必须配置 SCOUT_SMTP_HOST 和 SCOUT_SMTP_FROM")
}
return nil
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func csvEnv(name string) []string {
values := make([]string, 0)
for _, value := range strings.Split(os.Getenv(name), ",") {
value = strings.TrimSpace(value)
if value != "" {
values = append(values, strings.TrimRight(value, "/"))
}
}
return values
}
func boolEnv(name string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func intEnv(name string, fallback int) (int, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s 必须是整数", name)
}
return parsed, nil
}
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("%s 必须是大于 0 的时间长度,例如 24h 或 30s", name)
}
return parsed, nil
}
func durationDefault(name string, fallback time.Duration) time.Duration {
value, err := durationEnv(name, fallback)
if err != nil {
return fallback
}
return value
}
func bytesEnv(name string, fallback int64) (int64, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
multiplier := int64(1)
upper := strings.ToUpper(value)
switch {
case strings.HasSuffix(upper, "KB"):
multiplier = 1 << 10
value = strings.TrimSpace(value[:len(value)-2])
case strings.HasSuffix(upper, "MB"):
multiplier = 1 << 20
value = strings.TrimSpace(value[:len(value)-2])
case strings.HasSuffix(upper, "GB"):
multiplier = 1 << 30
value = strings.TrimSpace(value[:len(value)-2])
}
number, err := strconv.ParseInt(value, 10, 64)
if err != nil || number <= 0 {
return 0, fmt.Errorf("%s 必须是正整数或带 KB、MB、GB 后缀的大小", name)
}
return number * multiplier, nil
}