package config import ( "encoding/json" "os" "path/filepath" "strconv" "strings" ) const DefaultListen = ":33550" var Version = "0.1.0" type Config struct { BaseDir string `json:"base_dir"` ConfigPath string `json:"-"` Initialized bool `json:"initialized"` Listen string `json:"listen"` BaseURL string `json:"base_url"` StorageDir string `json:"storage_dir"` DataDir string `json:"data_dir"` UpdatePublicDir string `json:"update_public_dir"` UpdateNoticeDir string `json:"update_notice_dir"` DownloadsDir string `json:"downloads_dir"` AdminWebDir string `json:"admin_web_dir"` PortalWebDir string `json:"portal_web_dir"` SetupWebDir string `json:"setup_web_dir"` LegacyUpdateDir string `json:"legacy_update_dir"` LegacyFeedbackDir string `json:"legacy_feedback_dir"` LegacyUpdateNoticeDir string `json:"legacy_update_notice_dir"` ClientSignatureKey string `json:"client_signature_key"` PackageEncryptionKey string `json:"package_encryption_key"` TimestampWindowSeconds int64 `json:"timestamp_window_seconds"` MaxRequestBytes int64 `json:"max_request_bytes"` MaxPackageBytes int64 `json:"max_package_bytes"` Database DatabaseConfig `json:"database"` UploadGuard UploadGuardConfig `json:"upload_guard"` SourceCheckSeconds int `json:"source_check_seconds"` } type DatabaseConfig struct { Provider string `json:"provider"` SQLitePath string `json:"sqlite_path"` MySQLDSN string `json:"mysql_dsn"` FailoverEnabled bool `json:"failover_enabled"` HotSyncEnabled bool `json:"hot_sync_enabled"` HealthIntervalSec int `json:"health_interval_sec"` MaxOpenConns int `json:"max_open_conns"` MaxIdleConns int `json:"max_idle_conns"` ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"` } type UploadGuardConfig struct { MaxZipFiles int `json:"max_zip_files"` MaxDecompressedBytes int64 `json:"max_decompressed_bytes"` MaxSingleFileBytes int64 `json:"max_single_file_bytes"` MaxCompressionRatio float64 `json:"max_compression_ratio"` MaxReadableTextBytes int64 `json:"max_readable_text_bytes"` AllowUnexpectedZipFiles bool `json:"allow_unexpected_zip_files"` } func Load() (*Config, error) { root, err := ResolveBaseDir() if err != nil { return nil, err } cfg := defaults(root) path := firstNonEmpty(os.Getenv("YMHUT_UNIFIED_CONFIG"), filepath.Join(root, "config.json")) if data, err := os.ReadFile(path); err == nil { if err := json.Unmarshal(data, cfg); err != nil { return nil, err } cfg.Initialized = true } cfg.BaseDir = root cfg.ConfigPath = path applyEnv(cfg) normalize(root, cfg) return cfg, nil } func defaults(root string) *Config { return &Config{ BaseDir: root, ConfigPath: filepath.Join(root, "config.json"), Initialized: false, Listen: DefaultListen, BaseURL: "https://update.ymhut.cn", StorageDir: filepath.Join(root, "storage"), DataDir: filepath.Join(root, "data"), UpdatePublicDir: filepath.Join(root, "data", "update", "public"), UpdateNoticeDir: filepath.Join(root, "data", "update-notice"), DownloadsDir: filepath.Join(root, "data", "update", "public", "downloads"), AdminWebDir: filepath.Join(root, "web", "admin", "dist"), PortalWebDir: filepath.Join(root, "web", "portal", "dist"), SetupWebDir: filepath.Join(root, "web", "setup", "dist"), LegacyUpdateDir: filepath.Clean(filepath.Join(root, "..", "update")), LegacyFeedbackDir: filepath.Clean(filepath.Join(root, "..", "feedback-mailer")), LegacyUpdateNoticeDir: filepath.Clean(filepath.Join(root, "..", "..", "update-notice")), ClientSignatureKey: "ymhut-box-feedback-client-v1", PackageEncryptionKey: "ymhut-box-feedback-package-v1", TimestampWindowSeconds: 600, MaxRequestBytes: 12 * 1024 * 1024, MaxPackageBytes: 10 * 1024 * 1024, SourceCheckSeconds: 300, Database: DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(root, "storage", "unified.sqlite"), FailoverEnabled: true, HotSyncEnabled: true, HealthIntervalSec: 30, MaxOpenConns: 10, MaxIdleConns: 4, ConnMaxLifetimeSeconds: 300, }, UploadGuard: UploadGuardConfig{ MaxZipFiles: 80, MaxDecompressedBytes: 30 * 1024 * 1024, MaxSingleFileBytes: 8 * 1024 * 1024, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 * 1024, AllowUnexpectedZipFiles: true, }, } } func applyEnv(cfg *Config) { if value := os.Getenv("YMHUT_BASE_DIR"); value != "" { cfg.BaseDir = value } if value := os.Getenv("PORT"); value != "" { cfg.Listen = ":" + value } if value := os.Getenv("YMHUT_LISTEN"); value != "" { cfg.Listen = value } if value := os.Getenv("YMHUT_BASE_URL"); value != "" { cfg.BaseURL = value } if value := os.Getenv("YMHUT_STORAGE_DIR"); value != "" { cfg.StorageDir = value } if value := os.Getenv("YMHUT_DATA_DIR"); value != "" { cfg.DataDir = value } if value := os.Getenv("YMHUT_UPDATE_PUBLIC_DIR"); value != "" { cfg.UpdatePublicDir = value } if value := os.Getenv("YMHUT_UPDATE_NOTICE_DIR"); value != "" { cfg.UpdateNoticeDir = value } if value := os.Getenv("YMHUT_DOWNLOADS_DIR"); value != "" { cfg.DownloadsDir = value } if value := os.Getenv("YMHUT_LEGACY_UPDATE_DIR"); value != "" { cfg.LegacyUpdateDir = value } if value := os.Getenv("YMHUT_LEGACY_FEEDBACK_DIR"); value != "" { cfg.LegacyFeedbackDir = value } if value := os.Getenv("YMHUT_LEGACY_UPDATE_NOTICE_DIR"); value != "" { cfg.LegacyUpdateNoticeDir = value } if value := os.Getenv("YMHUT_DB_PROVIDER"); value != "" { cfg.Database.Provider = value } if value := os.Getenv("YMHUT_SQLITE_PATH"); value != "" { cfg.Database.SQLitePath = value } if value := os.Getenv("YMHUT_MYSQL_DSN"); value != "" { cfg.Database.MySQLDSN = value } if value := os.Getenv("YMHUT_CLIENT_SIGNATURE_KEY"); value != "" { cfg.ClientSignatureKey = value } if value := os.Getenv("YMHUT_PACKAGE_ENCRYPTION_KEY"); value != "" { cfg.PackageEncryptionKey = value } if value := os.Getenv("YMHUT_TIMESTAMP_WINDOW_SECONDS"); value != "" { if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { cfg.TimestampWindowSeconds = parsed } } if value := os.Getenv("YMHUT_MAX_REQUEST_BYTES"); value != "" { if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { cfg.MaxRequestBytes = parsed } } if value := os.Getenv("YMHUT_MAX_PACKAGE_BYTES"); value != "" { if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { cfg.MaxPackageBytes = parsed } } if value := os.Getenv("YMHUT_SOURCE_CHECK_SECONDS"); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.SourceCheckSeconds = parsed } } } func normalize(root string, cfg *Config) { cfg.BaseDir = absPath(root, firstNonEmpty(cfg.BaseDir, root)) if cfg.ConfigPath == "" { cfg.ConfigPath = filepath.Join(cfg.BaseDir, "config.json") } if cfg.Listen == "" { cfg.Listen = DefaultListen } if cfg.StorageDir == "" { cfg.StorageDir = filepath.Join(cfg.BaseDir, "storage") } cfg.StorageDir = absPath(cfg.BaseDir, cfg.StorageDir) if cfg.DataDir == "" { cfg.DataDir = filepath.Join(cfg.BaseDir, "data") } cfg.DataDir = absPath(cfg.BaseDir, cfg.DataDir) if cfg.UpdatePublicDir == "" { cfg.UpdatePublicDir = filepath.Join(cfg.DataDir, "update", "public") } cfg.UpdatePublicDir = absPath(cfg.BaseDir, cfg.UpdatePublicDir) if cfg.UpdateNoticeDir == "" { cfg.UpdateNoticeDir = filepath.Join(cfg.DataDir, "update-notice") } cfg.UpdateNoticeDir = absPath(cfg.BaseDir, cfg.UpdateNoticeDir) if cfg.DownloadsDir == "" { cfg.DownloadsDir = filepath.Join(cfg.UpdatePublicDir, "downloads") } cfg.DownloadsDir = absPath(cfg.BaseDir, cfg.DownloadsDir) if cfg.AdminWebDir == "" { cfg.AdminWebDir = filepath.Join(cfg.BaseDir, "web", "admin", "dist") } cfg.AdminWebDir = absPath(cfg.BaseDir, cfg.AdminWebDir) if cfg.PortalWebDir == "" { cfg.PortalWebDir = filepath.Join(cfg.BaseDir, "web", "portal", "dist") } cfg.PortalWebDir = absPath(cfg.BaseDir, cfg.PortalWebDir) if cfg.SetupWebDir == "" { cfg.SetupWebDir = filepath.Join(cfg.BaseDir, "web", "setup", "dist") } cfg.SetupWebDir = absPath(cfg.BaseDir, cfg.SetupWebDir) if cfg.LegacyUpdateDir == "" { cfg.LegacyUpdateDir = filepath.Clean(filepath.Join(cfg.BaseDir, "..", "update")) } cfg.LegacyUpdateDir = absPath(cfg.BaseDir, cfg.LegacyUpdateDir) if cfg.LegacyFeedbackDir == "" { cfg.LegacyFeedbackDir = filepath.Clean(filepath.Join(cfg.BaseDir, "..", "feedback-mailer")) } cfg.LegacyFeedbackDir = absPath(cfg.BaseDir, cfg.LegacyFeedbackDir) if cfg.LegacyUpdateNoticeDir == "" { cfg.LegacyUpdateNoticeDir = filepath.Clean(filepath.Join(cfg.BaseDir, "..", "..", "update-notice")) } cfg.LegacyUpdateNoticeDir = absPath(cfg.BaseDir, cfg.LegacyUpdateNoticeDir) if cfg.Database.Provider == "" { cfg.Database.Provider = "sqlite" } if cfg.Database.SQLitePath == "" { cfg.Database.SQLitePath = filepath.Join(cfg.StorageDir, "unified.sqlite") } cfg.Database.SQLitePath = absPath(cfg.BaseDir, cfg.Database.SQLitePath) if cfg.Database.HealthIntervalSec <= 0 { cfg.Database.HealthIntervalSec = 30 } if cfg.Database.MaxOpenConns <= 0 { cfg.Database.MaxOpenConns = 10 } if cfg.Database.MaxIdleConns <= 0 { cfg.Database.MaxIdleConns = 4 } if cfg.Database.ConnMaxLifetimeSeconds <= 0 { cfg.Database.ConnMaxLifetimeSeconds = 300 } if cfg.ClientSignatureKey == "" { cfg.ClientSignatureKey = "ymhut-box-feedback-client-v1" } if cfg.PackageEncryptionKey == "" { cfg.PackageEncryptionKey = "ymhut-box-feedback-package-v1" } if cfg.TimestampWindowSeconds <= 0 { cfg.TimestampWindowSeconds = 600 } if cfg.MaxRequestBytes <= 0 { cfg.MaxRequestBytes = 12 * 1024 * 1024 } if cfg.MaxPackageBytes <= 0 { cfg.MaxPackageBytes = 10 * 1024 * 1024 } if cfg.UploadGuard.MaxZipFiles <= 0 { cfg.UploadGuard.MaxZipFiles = 80 } if cfg.UploadGuard.MaxDecompressedBytes <= 0 { cfg.UploadGuard.MaxDecompressedBytes = 30 * 1024 * 1024 } if cfg.UploadGuard.MaxSingleFileBytes <= 0 { cfg.UploadGuard.MaxSingleFileBytes = 8 * 1024 * 1024 } if cfg.UploadGuard.MaxCompressionRatio <= 0 { cfg.UploadGuard.MaxCompressionRatio = 120 } if cfg.UploadGuard.MaxReadableTextBytes <= 0 { cfg.UploadGuard.MaxReadableTextBytes = 256 * 1024 } if cfg.SourceCheckSeconds <= 0 { cfg.SourceCheckSeconds = 300 } } func ResolveBaseDir() (string, error) { if value := os.Getenv("YMHUT_BASE_DIR"); value != "" { return filepath.Abs(value) } if cwd, err := os.Getwd(); err == nil { if filepath.Base(cwd) == "unified-management" { return filepath.Abs(cwd) } candidate := filepath.Join(cwd, "server", "unified-management") if info, err := os.Stat(candidate); err == nil && info.IsDir() { return filepath.Abs(candidate) } } exe, err := os.Executable() if err != nil { return os.Getwd() } return filepath.Abs(filepath.Dir(exe)) } func Save(cfg *Config) error { if cfg == nil { return nil } normalize(firstNonEmpty(cfg.BaseDir, "."), cfg) if err := os.MkdirAll(filepath.Dir(cfg.ConfigPath), 0o750); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(cfg.ConfigPath, data, 0o600) } func absPath(base, value string) string { if strings.TrimSpace(value) == "" { return value } if filepath.IsAbs(value) || strings.HasPrefix(strings.ToLower(value), "file:") { return filepath.Clean(value) } return filepath.Clean(filepath.Join(base, value)) } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" }