package config import ( "encoding/json" "os" "path/filepath" "runtime" "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"` Mail MailConfig `json:"mail"` Branding BrandingConfig `json:"branding"` 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"` MySQLHost string `json:"mysql_host"` MySQLPort int `json:"mysql_port"` MySQLDatabase string `json:"mysql_database"` MySQLUser string `json:"mysql_user"` MySQLPassword string `json:"mysql_password"` 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 MailConfig struct { Host string `json:"host"` Port int `json:"port"` Secure string `json:"secure"` Username string `json:"username"` Password string `json:"password"` FromAddress string `json:"from_address"` FromName string `json:"from_name"` DeveloperAddress string `json:"developer_address"` TimeoutSeconds int `json:"timeout_seconds"` } type BrandingConfig struct { SiteIconURL string `json:"site_icon_url"` DeveloperAvatarURL string `json:"developer_avatar_url"` DeveloperName string `json:"developer_name"` FeedbackEmail string `json:"feedback_email"` } 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")) var rawConfig []byte loaded := false if data, err := os.ReadFile(path); err == nil { if err := json.Unmarshal(data, cfg); err != nil { return nil, err } cfg.Initialized = true rawConfig = data loaded = true } cfg.BaseDir = root cfg.ConfigPath = path if loaded { sanitizeNonPortablePaths(cfg) } applyEnv(cfg) normalize(root, cfg) if loaded && shouldRewriteRelativeConfig(rawConfig) { if err := Save(cfg); err != nil { return nil, err } } 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"), MySQLHost: "127.0.0.1", MySQLPort: 3306, FailoverEnabled: true, HotSyncEnabled: true, HealthIntervalSec: 30, MaxOpenConns: 10, MaxIdleConns: 4, ConnMaxLifetimeSeconds: 300, }, Mail: MailConfig{ Port: 465, Secure: "ssl", FromName: "YMhut Box Feedback", DeveloperAddress: "support@ymhut.cn", TimeoutSeconds: 20, }, Branding: BrandingConfig{ SiteIconURL: "https://img.ymhut.cn/file/1782108850041_icon.webp", DeveloperAvatarURL: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp", DeveloperName: "YMhut", FeedbackEmail: "support@ymhut.cn", }, 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_MYSQL_HOST"); value != "" { cfg.Database.MySQLHost = value } if value := os.Getenv("YMHUT_MYSQL_PORT"); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Database.MySQLPort = parsed } } if value := os.Getenv("YMHUT_MYSQL_DATABASE"); value != "" { cfg.Database.MySQLDatabase = value } if value := os.Getenv("YMHUT_MYSQL_USER"); value != "" { cfg.Database.MySQLUser = value } if value := os.Getenv("YMHUT_MYSQL_PASSWORD"); value != "" { cfg.Database.MySQLPassword = value } if value := os.Getenv("YMHUT_MAIL_HOST"); value != "" { cfg.Mail.Host = value } if value := os.Getenv("YMHUT_MAIL_PORT"); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Mail.Port = parsed } } if value := os.Getenv("YMHUT_MAIL_SECURE"); value != "" { cfg.Mail.Secure = value } if value := os.Getenv("YMHUT_MAIL_USERNAME"); value != "" { cfg.Mail.Username = value } if value := os.Getenv("YMHUT_MAIL_PASSWORD"); value != "" { cfg.Mail.Password = value } if value := os.Getenv("YMHUT_MAIL_FROM_ADDRESS"); value != "" { cfg.Mail.FromAddress = value } if value := os.Getenv("YMHUT_MAIL_FROM_NAME"); value != "" { cfg.Mail.FromName = value } if value := os.Getenv("YMHUT_MAIL_DEVELOPER_ADDRESS"); value != "" { cfg.Mail.DeveloperAddress = value } if value := os.Getenv("YMHUT_MAIL_TIMEOUT_SECONDS"); value != "" { if parsed, err := strconv.Atoi(value); err == nil { cfg.Mail.TimeoutSeconds = parsed } } if value := os.Getenv("YMHUT_BRAND_ICON_URL"); value != "" { cfg.Branding.SiteIconURL = value } if value := os.Getenv("YMHUT_BRAND_DEVELOPER_AVATAR_URL"); value != "" { cfg.Branding.DeveloperAvatarURL = value } if value := os.Getenv("YMHUT_BRAND_DEVELOPER_NAME"); value != "" { cfg.Branding.DeveloperName = value } if value := os.Getenv("YMHUT_BRAND_FEEDBACK_EMAIL"); value != "" { cfg.Branding.FeedbackEmail = 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" } cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider)) 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.MySQLHost == "" { cfg.Database.MySQLHost = "127.0.0.1" } if cfg.Database.MySQLPort <= 0 { cfg.Database.MySQLPort = 3306 } if cfg.Database.Provider == "mysql" && cfg.Database.MySQLDSN == "" && cfg.Database.MySQLDatabase != "" && cfg.Database.MySQLUser != "" { if dsn, err := BuildMySQLDSN(MySQLInput{ Host: cfg.Database.MySQLHost, Port: cfg.Database.MySQLPort, Database: cfg.Database.MySQLDatabase, Username: cfg.Database.MySQLUser, Password: cfg.Database.MySQLPassword, Charset: "utf8mb4", ParseTime: true, }); err == nil { cfg.Database.MySQLDSN = dsn } } 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 } if cfg.Mail.Port <= 0 { cfg.Mail.Port = 465 } cfg.Mail.Secure = strings.ToLower(strings.TrimSpace(cfg.Mail.Secure)) if cfg.Mail.Secure == "" { cfg.Mail.Secure = "ssl" } if cfg.Mail.FromName == "" { cfg.Mail.FromName = "YMhut Box Feedback" } if cfg.Mail.FromAddress == "" { cfg.Mail.FromAddress = cfg.Mail.Username } if cfg.Mail.TimeoutSeconds <= 0 { cfg.Mail.TimeoutSeconds = 20 } if cfg.Branding.SiteIconURL == "" { cfg.Branding.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp" } if cfg.Branding.DeveloperAvatarURL == "" { cfg.Branding.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp" } if cfg.Branding.DeveloperName == "" { cfg.Branding.DeveloperName = "YMhut" } if cfg.Branding.FeedbackEmail == "" { cfg.Branding.FeedbackEmail = "support@ymhut.cn" } if cfg.Mail.DeveloperAddress == "" { cfg.Mail.DeveloperAddress = cfg.Branding.FeedbackEmail } } 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 } persisted := cfg.relativeCopy() data, err := json.MarshalIndent(persisted, "", " ") if err != nil { return err } return os.WriteFile(cfg.ConfigPath, data, 0o600) } func (cfg *Config) relativeCopy() Config { next := *cfg base := cfg.BaseDir next.BaseDir = "." next.StorageDir = relativePath(base, cfg.StorageDir) next.DataDir = relativePath(base, cfg.DataDir) next.UpdatePublicDir = relativePath(base, cfg.UpdatePublicDir) next.UpdateNoticeDir = relativePath(base, cfg.UpdateNoticeDir) next.DownloadsDir = relativePath(base, cfg.DownloadsDir) next.AdminWebDir = relativePath(base, cfg.AdminWebDir) next.PortalWebDir = relativePath(base, cfg.PortalWebDir) next.SetupWebDir = relativePath(base, cfg.SetupWebDir) next.LegacyUpdateDir = relativePath(base, cfg.LegacyUpdateDir) next.LegacyFeedbackDir = relativePath(base, cfg.LegacyFeedbackDir) next.LegacyUpdateNoticeDir = relativePath(base, cfg.LegacyUpdateNoticeDir) next.Database.SQLitePath = relativePath(base, cfg.Database.SQLitePath) return next } func relativePath(base, value string) string { if strings.TrimSpace(value) == "" || strings.HasPrefix(strings.ToLower(value), "file:") { return value } rel, err := filepath.Rel(base, value) if err != nil || rel == "" { return value } if strings.HasPrefix(rel, "..") { return filepath.ToSlash(rel) } return filepath.ToSlash(rel) } func sanitizeNonPortablePaths(cfg *Config) { if runtime.GOOS == "windows" { return } for _, target := range []*string{ &cfg.StorageDir, &cfg.DataDir, &cfg.UpdatePublicDir, &cfg.UpdateNoticeDir, &cfg.DownloadsDir, &cfg.AdminWebDir, &cfg.PortalWebDir, &cfg.SetupWebDir, &cfg.LegacyUpdateDir, &cfg.LegacyFeedbackDir, &cfg.LegacyUpdateNoticeDir, &cfg.Database.SQLitePath, } { if isWindowsAbsolutePath(*target) { *target = "" } } } func shouldRewriteRelativeConfig(data []byte) bool { var payload any if len(data) == 0 || json.Unmarshal(data, &payload) != nil { return false } return containsAbsolutePath(payload) } func containsAbsolutePath(value any) bool { switch typed := value.(type) { case map[string]any: for _, item := range typed { if containsAbsolutePath(item) { return true } } case []any: for _, item := range typed { if containsAbsolutePath(item) { return true } } case string: return filepath.IsAbs(typed) || isWindowsAbsolutePath(typed) } return false } func isWindowsAbsolutePath(value string) bool { value = strings.TrimSpace(value) if len(value) >= 3 { drive := value[0] if ((drive >= 'A' && drive <= 'Z') || (drive >= 'a' && drive <= 'z')) && value[1] == ':' && (value[2] == '\\' || value[2] == '/') { return true } } return strings.HasPrefix(value, `\\`) } 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 "" }