Files
YMhut-box-C-/server/unified-management/internal/config/config.go
T
QWQLwToo 962a2f2143
build-winui / winui (push) Waiting to run
更新 update 门户站点界面和后台功能
2026-06-27 18:09:11 +08:00

623 lines
20 KiB
Go

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 ""
}