package config import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" ) const ( defaultClientSignatureKey = "ymhut-box-feedback-client-v1" defaultPackageEncryptionKey = "ymhut-box-feedback-package-v1" ) type Config struct { BaseDir string `json:"-"` Listen string `json:"listen"` AdminPasswordHash string `json:"admin_password_hash"` AdminPassword string `json:"admin_password"` 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"` StorageDir string `json:"storage_dir"` DatabasePath string `json:"database_path"` Mail MailConfig `json:"mail"` RateLimit RateLimitConfig `json:"rate_limit"` UploadGuard UploadGuardConfig `json:"upload_guard"` Backup BackupConfig `json:"backup"` Database DatabaseConfig `json:"database"` Webhooks []WebhookConfig `json:"webhooks"` } 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 RateLimitConfig struct { SubmissionPerMinute int `json:"submission_per_minute"` SubmissionBurst int `json:"submission_burst"` StatusPerMinute int `json:"status_per_minute"` StatusBurst int `json:"status_burst"` CaptchaPerMinute int `json:"captcha_per_minute"` CaptchaBurst int `json:"captcha_burst"` LoginPerMinute int `json:"login_per_minute"` LoginBurst int `json:"login_burst"` AdminReadPerMinute int `json:"admin_read_per_minute"` AdminReadBurst int `json:"admin_read_burst"` AdminWritePerMinute int `json:"admin_write_per_minute"` AdminWriteBurst int `json:"admin_write_burst"` } 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"` } type BackupConfig struct { Dir string `json:"dir"` } type DatabaseConfig struct { Provider string `json:"provider"` SQLitePath string `json:"sqlite_path"` Host string `json:"host"` Port int `json:"port"` Name string `json:"name"` User string `json:"user"` Password string `json:"password"` DSN string `json:"dsn"` SSLMode string `json:"ssl_mode"` MaxOpenConns int `json:"max_open_conns"` MaxIdleConns int `json:"max_idle_conns"` ConnMaxLifetimeSeconds int `json:"conn_max_lifetime_seconds"` FailoverEnabled bool `json:"failover_enabled"` HealthIntervalSeconds int `json:"health_interval_seconds"` Sync DatabaseSyncConfig `json:"sync"` } type DatabaseSyncConfig struct { Enabled bool `json:"enabled"` IntervalSeconds int `json:"interval_seconds"` BatchSize int `json:"batch_size"` } type WebhookConfig struct { Name string `json:"name"` URL string `json:"url"` Secret string `json:"secret"` Enabled bool `json:"enabled"` Events []string `json:"events"` TimeoutSeconds int `json:"timeout_seconds"` MaxRetries int `json:"max_retries"` } func Load(baseDir string) (*Config, error) { absBase, err := filepath.Abs(baseDir) if err != nil { return nil, err } cfg := Default(absBase) for _, name := range []string{"config.txt", "config.json"} { path := filepath.Join(absBase, name) if isFile(path) { if strings.EqualFold(filepath.Ext(path), ".json") { if err := loadJSON(path, cfg); err != nil { return nil, err } } else { if err := loadLegacy(path, cfg); err != nil { return nil, err } } normalize(cfg) return cfg, nil } } normalize(cfg) return cfg, nil } func Default(baseDir string) *Config { return &Config{ BaseDir: baseDir, Listen: ":8080", AdminPasswordHash: "", AdminPassword: "CHANGE_ME_ADMIN_PASSWORD", ClientSignatureKey: defaultClientSignatureKey, PackageEncryptionKey: defaultPackageEncryptionKey, TimestampWindowSeconds: 600, MaxRequestBytes: 12 * 1024 * 1024, MaxPackageBytes: 10 * 1024 * 1024, StorageDir: filepath.Join(baseDir, "storage"), DatabasePath: filepath.Join(baseDir, "storage", "feedback.sqlite"), Mail: MailConfig{ Host: "mail.example.com", Port: 465, Secure: "ssl", Username: "sender@example.com", Password: "CHANGE_ME_MAIL_PASSWORD", FromAddress: "sender@example.com", FromName: "YMhut Box Feedback", DeveloperAddress: "developer@example.com", TimeoutSeconds: 20, }, RateLimit: RateLimitConfig{ SubmissionPerMinute: 20, SubmissionBurst: 5, StatusPerMinute: 120, StatusBurst: 30, CaptchaPerMinute: 60, CaptchaBurst: 10, LoginPerMinute: 12, LoginBurst: 3, AdminReadPerMinute: 300, AdminReadBurst: 60, AdminWritePerMinute: 90, AdminWriteBurst: 20, }, UploadGuard: UploadGuardConfig{ MaxZipFiles: 80, MaxDecompressedBytes: 30 * 1024 * 1024, MaxSingleFileBytes: 8 * 1024 * 1024, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 * 1024, AllowUnexpectedZipFiles: true, }, Backup: BackupConfig{ Dir: filepath.Join(baseDir, "storage", "backups"), }, Database: DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(baseDir, "storage", "feedback.sqlite"), Port: 0, SSLMode: "disable", MaxOpenConns: 10, MaxIdleConns: 4, ConnMaxLifetimeSeconds: 300, FailoverEnabled: true, HealthIntervalSeconds: 30, Sync: DatabaseSyncConfig{ Enabled: true, IntervalSeconds: 24 * 60 * 60, BatchSize: 500, }, }, Webhooks: []WebhookConfig{}, } } func loadJSON(path string, cfg *Config) error { data, err := os.ReadFile(path) if err != nil { return err } if err := json.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse %s: %w", filepath.Base(path), err) } return nil } func loadLegacy(path string, cfg *Config) error { data, err := os.ReadFile(path) if err != nil { return err } text := string(data) vars := parseLegacyVars(text) entries := parseLegacyEntries(text, vars, filepath.Dir(path)) if len(entries) == 0 { return errors.New("legacy config did not contain any parseable entries") } assignString(entries, "admin_password_hash", &cfg.AdminPasswordHash) assignString(entries, "admin_password", &cfg.AdminPassword) assignString(entries, "listen", &cfg.Listen) assignString(entries, "client_signature_key", &cfg.ClientSignatureKey) assignString(entries, "package_encryption_key", &cfg.PackageEncryptionKey) assignInt64(entries, "timestamp_window_seconds", &cfg.TimestampWindowSeconds) assignInt64(entries, "max_request_bytes", &cfg.MaxRequestBytes) assignInt64(entries, "max_package_bytes", &cfg.MaxPackageBytes) assignString(entries, "storage_dir", &cfg.StorageDir) assignString(entries, "database_path", &cfg.DatabasePath) assignString(entries, "database_provider", &cfg.Database.Provider) assignString(entries, "database_sqlite_path", &cfg.Database.SQLitePath) assignString(entries, "database_host", &cfg.Database.Host) assignInt(entries, "database_port", &cfg.Database.Port) assignString(entries, "database_name", &cfg.Database.Name) assignString(entries, "database_user", &cfg.Database.User) assignString(entries, "database_password", &cfg.Database.Password) assignString(entries, "database_dsn", &cfg.Database.DSN) assignString(entries, "database_ssl_mode", &cfg.Database.SSLMode) assignInt(entries, "database_max_open_conns", &cfg.Database.MaxOpenConns) assignInt(entries, "database_max_idle_conns", &cfg.Database.MaxIdleConns) assignInt(entries, "database_conn_max_lifetime_seconds", &cfg.Database.ConnMaxLifetimeSeconds) assignBool(entries, "database_failover_enabled", &cfg.Database.FailoverEnabled) assignInt(entries, "database_health_interval_seconds", &cfg.Database.HealthIntervalSeconds) assignBool(entries, "database_sync_enabled", &cfg.Database.Sync.Enabled) assignInt(entries, "database_sync_interval_seconds", &cfg.Database.Sync.IntervalSeconds) assignInt(entries, "database_sync_batch_size", &cfg.Database.Sync.BatchSize) assignString(entries, "host", &cfg.Mail.Host) assignInt(entries, "port", &cfg.Mail.Port) assignString(entries, "secure", &cfg.Mail.Secure) assignString(entries, "username", &cfg.Mail.Username) assignString(entries, "password", &cfg.Mail.Password) assignString(entries, "from_address", &cfg.Mail.FromAddress) assignString(entries, "from_name", &cfg.Mail.FromName) assignString(entries, "developer_address", &cfg.Mail.DeveloperAddress) assignInt(entries, "timeout_seconds", &cfg.Mail.TimeoutSeconds) assignIntAliases(entries, &cfg.RateLimit.SubmissionPerMinute, "submission_per_minute", "rate_limit_submission_per_minute") assignIntAliases(entries, &cfg.RateLimit.SubmissionBurst, "submission_burst", "rate_limit_submission_burst") assignIntAliases(entries, &cfg.RateLimit.StatusPerMinute, "status_per_minute", "rate_limit_status_per_minute") assignIntAliases(entries, &cfg.RateLimit.StatusBurst, "status_burst", "rate_limit_status_burst") assignIntAliases(entries, &cfg.RateLimit.CaptchaPerMinute, "captcha_per_minute", "rate_limit_captcha_per_minute") assignIntAliases(entries, &cfg.RateLimit.CaptchaBurst, "captcha_burst", "rate_limit_captcha_burst") assignIntAliases(entries, &cfg.RateLimit.LoginPerMinute, "login_per_minute", "rate_limit_login_per_minute") assignIntAliases(entries, &cfg.RateLimit.LoginBurst, "login_burst", "rate_limit_login_burst") assignIntAliases(entries, &cfg.RateLimit.AdminReadPerMinute, "admin_read_per_minute", "rate_limit_admin_read_per_minute") assignIntAliases(entries, &cfg.RateLimit.AdminReadBurst, "admin_read_burst", "rate_limit_admin_read_burst") assignIntAliases(entries, &cfg.RateLimit.AdminWritePerMinute, "admin_write_per_minute", "rate_limit_admin_write_per_minute") assignIntAliases(entries, &cfg.RateLimit.AdminWriteBurst, "admin_write_burst", "rate_limit_admin_write_burst") assignInt(entries, "max_zip_files", &cfg.UploadGuard.MaxZipFiles) assignInt64(entries, "max_decompressed_bytes", &cfg.UploadGuard.MaxDecompressedBytes) assignInt64(entries, "max_single_file_bytes", &cfg.UploadGuard.MaxSingleFileBytes) assignFloat(entries, "max_compression_ratio", &cfg.UploadGuard.MaxCompressionRatio) assignInt64(entries, "max_readable_text_bytes", &cfg.UploadGuard.MaxReadableTextBytes) assignBool(entries, "allow_unexpected_zip_files", &cfg.UploadGuard.AllowUnexpectedZipFiles) assignString(entries, "backup_dir", &cfg.Backup.Dir) if hooks := parseLegacyWebhooks(text, vars, filepath.Dir(path)); len(hooks) > 0 { cfg.Webhooks = hooks } return nil } func Save(cfg *Config) error { if cfg == nil { return errors.New("config is nil") } normalize(cfg) path := filepath.Join(cfg.BaseDir, "config.txt") if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } if isFile(path) { backup := filepath.Join(cfg.BaseDir, "config-"+time.Now().UTC().Format("20060102-150405")+".bak") data, err := os.ReadFile(path) if err != nil { return err } if err := os.WriteFile(backup, data, 0o600); err != nil { return err } } return os.WriteFile(path, []byte(RenderLegacy(cfg)), 0o600) } func RenderLegacy(cfg *Config) string { var b strings.Builder b.WriteString(" ") b.WriteString(quotePHP(value)) b.WriteString(",\n") } writeInt := func(key string, value int64) { b.WriteString(" '") b.WriteString(key) b.WriteString("' => ") b.WriteString(strconv.FormatInt(value, 10)) b.WriteString(",\n") } writeBool := func(key string, value bool) { b.WriteString(" '") b.WriteString(key) b.WriteString("' => ") if value { b.WriteString("true") } else { b.WriteString("false") } b.WriteString(",\n") } writeKV("listen", cfg.Listen) writeKV("admin_password_hash", cfg.AdminPasswordHash) writeKV("admin_password", cfg.AdminPassword) writeKV("client_signature_key", cfg.ClientSignatureKey) writeKV("package_encryption_key", cfg.PackageEncryptionKey) writeInt("timestamp_window_seconds", cfg.TimestampWindowSeconds) writeInt("max_request_bytes", cfg.MaxRequestBytes) writeInt("max_package_bytes", cfg.MaxPackageBytes) writeKV("storage_dir", portablePath(cfg, cfg.StorageDir)) writeKV("database_path", portablePath(cfg, cfg.Database.SQLitePath)) writeKV("database_provider", cfg.Database.Provider) writeKV("database_sqlite_path", portablePath(cfg, cfg.Database.SQLitePath)) writeKV("database_host", cfg.Database.Host) writeInt("database_port", int64(cfg.Database.Port)) writeKV("database_name", cfg.Database.Name) writeKV("database_user", cfg.Database.User) writeKV("database_password", cfg.Database.Password) writeKV("database_dsn", cfg.Database.DSN) writeKV("database_ssl_mode", cfg.Database.SSLMode) writeInt("database_max_open_conns", int64(cfg.Database.MaxOpenConns)) writeInt("database_max_idle_conns", int64(cfg.Database.MaxIdleConns)) writeInt("database_conn_max_lifetime_seconds", int64(cfg.Database.ConnMaxLifetimeSeconds)) writeBool("database_failover_enabled", cfg.Database.FailoverEnabled) writeInt("database_health_interval_seconds", int64(cfg.Database.HealthIntervalSeconds)) writeBool("database_sync_enabled", cfg.Database.Sync.Enabled) writeInt("database_sync_interval_seconds", int64(cfg.Database.Sync.IntervalSeconds)) writeInt("database_sync_batch_size", int64(cfg.Database.Sync.BatchSize)) writeKV("host", cfg.Mail.Host) writeInt("port", int64(cfg.Mail.Port)) writeKV("secure", cfg.Mail.Secure) writeKV("username", cfg.Mail.Username) writeKV("password", cfg.Mail.Password) writeKV("from_address", cfg.Mail.FromAddress) writeKV("from_name", cfg.Mail.FromName) writeKV("developer_address", cfg.Mail.DeveloperAddress) writeInt("timeout_seconds", int64(cfg.Mail.TimeoutSeconds)) writeKV("backup_dir", portablePath(cfg, cfg.Backup.Dir)) writeInt("submission_per_minute", int64(cfg.RateLimit.SubmissionPerMinute)) writeInt("submission_burst", int64(cfg.RateLimit.SubmissionBurst)) writeInt("status_per_minute", int64(cfg.RateLimit.StatusPerMinute)) writeInt("status_burst", int64(cfg.RateLimit.StatusBurst)) writeInt("captcha_per_minute", int64(cfg.RateLimit.CaptchaPerMinute)) writeInt("captcha_burst", int64(cfg.RateLimit.CaptchaBurst)) writeInt("login_per_minute", int64(cfg.RateLimit.LoginPerMinute)) writeInt("login_burst", int64(cfg.RateLimit.LoginBurst)) writeInt("admin_read_per_minute", int64(cfg.RateLimit.AdminReadPerMinute)) writeInt("admin_read_burst", int64(cfg.RateLimit.AdminReadBurst)) writeInt("admin_write_per_minute", int64(cfg.RateLimit.AdminWritePerMinute)) writeInt("admin_write_burst", int64(cfg.RateLimit.AdminWriteBurst)) writeInt("max_zip_files", int64(cfg.UploadGuard.MaxZipFiles)) writeInt("max_decompressed_bytes", cfg.UploadGuard.MaxDecompressedBytes) writeInt("max_single_file_bytes", cfg.UploadGuard.MaxSingleFileBytes) b.WriteString(" 'max_compression_ratio' => ") b.WriteString(strconv.FormatFloat(cfg.UploadGuard.MaxCompressionRatio, 'f', -1, 64)) b.WriteString(",\n") writeInt("max_readable_text_bytes", cfg.UploadGuard.MaxReadableTextBytes) writeBool("allow_unexpected_zip_files", cfg.UploadGuard.AllowUnexpectedZipFiles) b.WriteString(" 'webhooks' => [\n") for _, hook := range cfg.Webhooks { b.WriteString(" [\n") b.WriteString(" 'name' => " + quotePHP(hook.Name) + ",\n") b.WriteString(" 'url' => " + quotePHP(hook.URL) + ",\n") b.WriteString(" 'secret' => " + quotePHP(hook.Secret) + ",\n") if hook.Enabled { b.WriteString(" 'enabled' => true,\n") } else { b.WriteString(" 'enabled' => false,\n") } b.WriteString(" 'events' => [") for index, event := range hook.Events { if index > 0 { b.WriteString(", ") } b.WriteString(quotePHP(event)) } b.WriteString("],\n") b.WriteString(" 'timeout_seconds' => " + strconv.Itoa(hook.TimeoutSeconds) + ",\n") b.WriteString(" 'max_retries' => " + strconv.Itoa(hook.MaxRetries) + ",\n") b.WriteString(" ],\n") } b.WriteString(" ],\n") b.WriteString("];\n") return b.String() } func quotePHP(value string) string { replacer := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\r", `\r`, "\n", `\n`) return "'" + replacer.Replace(value) + "'" } func portablePath(cfg *Config, value string) string { value = strings.TrimSpace(value) if value == "" || cfg == nil || strings.TrimSpace(cfg.BaseDir) == "" { return value } absValue, err := filepath.Abs(value) if err != nil { return value } absBase, err := filepath.Abs(cfg.BaseDir) if err != nil { return value } rel, err := filepath.Rel(absBase, absValue) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { return value } return filepath.ToSlash(rel) } var ( legacyVarPattern = regexp.MustCompile(`(?s)\$([A-Za-z_][A-Za-z0-9_]*)\s*=\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")\s*;`) legacyEntryPattern = regexp.MustCompile(`(?s)['"]([A-Za-z0-9_]+)['"]\s*=>\s*([^,\]\r\n]+(?:\s*\*\s*\d+)*)`) ) func parseLegacyVars(text string) map[string]string { vars := map[string]string{} for _, match := range legacyVarPattern.FindAllStringSubmatch(text, -1) { if len(match) == 3 { vars[match[1]] = stripQuoted(match[2]) } } return vars } func parseLegacyEntries(text string, vars map[string]string, dir string) map[string]string { entries := map[string]string{} for _, match := range legacyEntryPattern.FindAllStringSubmatch(text, -1) { if len(match) != 3 { continue } key := match[1] value := strings.TrimSpace(match[2]) entries[key] = parseLegacyValue(value, vars, dir) } return entries } func parseLegacyValue(value string, vars map[string]string, dir string) string { value = strings.TrimSpace(value) if strings.HasPrefix(value, "__DIR__") { re := regexp.MustCompile(`__DIR__\s*\.\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")`) if match := re.FindStringSubmatch(value); len(match) == 2 { return filepath.Clean(dir + filepath.FromSlash(stripQuoted(match[1]))) } return dir } if strings.HasPrefix(value, "$") { name := strings.TrimPrefix(value, "$") if parsed, ok := vars[name]; ok { return parsed } return "" } if strings.HasPrefix(value, "'") || strings.HasPrefix(value, `"`) { return stripQuoted(value) } if result, ok := evalIntExpression(value); ok { return strconv.FormatInt(result, 10) } return strings.Trim(value, " \t;") } func parseLegacyWebhooks(text string, vars map[string]string, dir string) []WebhookConfig { block, ok := findLegacyArrayBlock(text, "webhooks") if !ok { return nil } items := splitNestedArrayItems(block) hooks := make([]WebhookConfig, 0, len(items)) for _, item := range items { entries := parseLegacyEntries(item, vars, dir) if len(entries) == 0 { continue } hook := WebhookConfig{Enabled: true, TimeoutSeconds: 5, MaxRetries: 2} assignString(entries, "name", &hook.Name) assignString(entries, "url", &hook.URL) assignString(entries, "secret", &hook.Secret) assignBool(entries, "enabled", &hook.Enabled) assignInt(entries, "timeout_seconds", &hook.TimeoutSeconds) assignInt(entries, "max_retries", &hook.MaxRetries) hook.Events = parseStringArrayValue(item, "events") if hook.Name == "" && hook.URL != "" { hook.Name = "webhook" } if hook.URL != "" { hooks = append(hooks, hook) } } return hooks } func findLegacyArrayBlock(text, key string) (string, bool) { re := regexp.MustCompile(`['"]` + regexp.QuoteMeta(key) + `['"]\s*=>\s*\[`) loc := re.FindStringIndex(text) if loc == nil { return "", false } open := loc[1] - 1 depth := 0 for index := open; index < len(text); index++ { switch text[index] { case '[': depth++ case ']': depth-- if depth == 0 { return text[open+1 : index], true } } } return "", false } func splitNestedArrayItems(block string) []string { items := []string{} depth := 0 start := -1 for index := 0; index < len(block); index++ { switch block[index] { case '[': if depth == 0 { start = index + 1 } depth++ case ']': depth-- if depth == 0 && start >= 0 { items = append(items, block[start:index]) start = -1 } } } return items } func parseStringArrayValue(block, key string) []string { sub, ok := findLegacyArrayBlock(block, key) if !ok { return nil } re := regexp.MustCompile(`'(?:\\.|[^'])*'|"(?:\\.|[^"])*"`) values := []string{} for _, raw := range re.FindAllString(sub, -1) { if value := strings.TrimSpace(stripQuoted(raw)); value != "" { values = append(values, value) } } return values } func stripQuoted(value string) string { value = strings.TrimSpace(value) if len(value) < 2 { return value } quote := value[0] if (quote != '\'' && quote != '"') || value[len(value)-1] != quote { return value } body := value[1 : len(value)-1] body = strings.ReplaceAll(body, `\\`, `\`) if quote == '\'' { body = strings.ReplaceAll(body, `\'`, `'`) } else { body = strings.ReplaceAll(body, `\"`, `"`) } return body } func evalIntExpression(value string) (int64, bool) { parts := strings.Split(value, "*") total := int64(1) seen := false for _, part := range parts { part = strings.TrimSpace(strings.TrimSuffix(part, ";")) if part == "" { return 0, false } number, err := strconv.ParseInt(part, 10, 64) if err != nil { return 0, false } total *= number seen = true } return total, seen } func normalize(cfg *Config) { if cfg.BaseDir == "" { cfg.BaseDir, _ = os.Getwd() } if cfg.ClientSignatureKey == "" { cfg.ClientSignatureKey = defaultClientSignatureKey } if cfg.PackageEncryptionKey == "" { cfg.PackageEncryptionKey = defaultPackageEncryptionKey } 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.StorageDir == "" { cfg.StorageDir = filepath.Join(cfg.BaseDir, "storage") } if cfg.DatabasePath == "" { cfg.DatabasePath = filepath.Join(cfg.StorageDir, "feedback.sqlite") } if cfg.Database.Provider == "" { cfg.Database.Provider = "sqlite" } cfg.Database.Provider = strings.ToLower(strings.TrimSpace(cfg.Database.Provider)) if cfg.Database.Provider != "mysql" && cfg.Database.Provider != "postgres" && cfg.Database.Provider != "pgsql" { cfg.Database.Provider = "sqlite" } if cfg.Database.Provider == "pgsql" { cfg.Database.Provider = "postgres" } if cfg.Database.SQLitePath == "" { cfg.Database.SQLitePath = cfg.DatabasePath } if cfg.Backup.Dir == "" { cfg.Backup.Dir = filepath.Join(cfg.StorageDir, "backups") } cfg.StorageDir = normalizePath(cfg.BaseDir, cfg.StorageDir) cfg.DatabasePath = normalizePath(cfg.BaseDir, cfg.DatabasePath) cfg.Database.SQLitePath = normalizePath(cfg.BaseDir, cfg.Database.SQLitePath) cfg.DatabasePath = cfg.Database.SQLitePath cfg.Backup.Dir = normalizePath(cfg.BaseDir, cfg.Backup.Dir) if cfg.Database.Port <= 0 { switch cfg.Database.Provider { case "mysql": cfg.Database.Port = 3306 case "postgres": cfg.Database.Port = 5432 } } if cfg.Database.SSLMode == "" { cfg.Database.SSLMode = "disable" } if cfg.Database.MaxOpenConns <= 0 { cfg.Database.MaxOpenConns = 10 } if cfg.Database.MaxIdleConns < 0 { cfg.Database.MaxIdleConns = 0 } if cfg.Database.MaxIdleConns == 0 { cfg.Database.MaxIdleConns = 4 } if cfg.Database.ConnMaxLifetimeSeconds <= 0 { cfg.Database.ConnMaxLifetimeSeconds = 300 } if cfg.Database.HealthIntervalSeconds <= 0 { cfg.Database.HealthIntervalSeconds = 30 } if cfg.Database.Sync.IntervalSeconds <= 0 { cfg.Database.Sync.IntervalSeconds = 24 * 60 * 60 } if cfg.Database.Sync.BatchSize <= 0 { cfg.Database.Sync.BatchSize = 500 } if cfg.Mail.Port <= 0 { cfg.Mail.Port = 465 } if cfg.Mail.Secure == "" { cfg.Mail.Secure = "ssl" } cfg.Mail.Secure = strings.ToLower(cfg.Mail.Secure) if cfg.Mail.FromAddress == "" { cfg.Mail.FromAddress = cfg.Mail.Username } if cfg.Mail.FromName == "" { cfg.Mail.FromName = "YMhut Box Feedback" } if cfg.Mail.TimeoutSeconds <= 0 { cfg.Mail.TimeoutSeconds = 20 } if cfg.RateLimit.SubmissionPerMinute <= 0 { cfg.RateLimit.SubmissionPerMinute = 20 } if cfg.RateLimit.SubmissionBurst <= 0 { cfg.RateLimit.SubmissionBurst = 5 } if cfg.RateLimit.StatusPerMinute <= 0 { cfg.RateLimit.StatusPerMinute = 120 } if cfg.RateLimit.StatusBurst <= 0 { cfg.RateLimit.StatusBurst = 30 } if cfg.RateLimit.CaptchaPerMinute <= 0 { cfg.RateLimit.CaptchaPerMinute = 60 } if cfg.RateLimit.CaptchaBurst <= 0 { cfg.RateLimit.CaptchaBurst = 10 } if cfg.RateLimit.LoginPerMinute <= 0 { cfg.RateLimit.LoginPerMinute = 12 } if cfg.RateLimit.LoginBurst <= 0 { cfg.RateLimit.LoginBurst = 3 } if cfg.RateLimit.AdminReadPerMinute <= 0 { cfg.RateLimit.AdminReadPerMinute = 300 } if cfg.RateLimit.AdminReadBurst <= 0 { cfg.RateLimit.AdminReadBurst = 60 } if cfg.RateLimit.AdminWritePerMinute <= 0 { cfg.RateLimit.AdminWritePerMinute = 90 } if cfg.RateLimit.AdminWriteBurst <= 0 { cfg.RateLimit.AdminWriteBurst = 20 } 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 } for index := range cfg.Webhooks { hook := &cfg.Webhooks[index] hook.Name = strings.TrimSpace(hook.Name) hook.URL = strings.TrimSpace(hook.URL) if hook.TimeoutSeconds <= 0 { hook.TimeoutSeconds = 5 } if hook.MaxRetries < 0 { hook.MaxRetries = 0 } if len(hook.Events) == 0 { hook.Events = []string{"feedback.created", "feedback.updated", "feedback.status_changed", "feedback.comment_created", "mail.failed"} } } } func normalizePath(baseDir, value string) string { value = strings.TrimSpace(value) if value == "" { return value } if filepath.IsAbs(value) { return filepath.Clean(value) } return filepath.Clean(filepath.Join(baseDir, value)) } func assignString(entries map[string]string, key string, target *string) { if value, ok := entries[key]; ok { *target = value } } func assignInt(entries map[string]string, key string, target *int) { if value, ok := entries[key]; ok { if parsed, err := strconv.Atoi(value); err == nil { *target = parsed } } } func assignIntAliases(entries map[string]string, target *int, keys ...string) { for _, key := range keys { if _, ok := entries[key]; ok { assignInt(entries, key, target) return } } } func assignInt64(entries map[string]string, key string, target *int64) { if value, ok := entries[key]; ok { if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { *target = parsed } } } func assignFloat(entries map[string]string, key string, target *float64) { if value, ok := entries[key]; ok { if parsed, err := strconv.ParseFloat(value, 64); err == nil { *target = parsed } } } func assignBool(entries map[string]string, key string, target *bool) { if value, ok := entries[key]; ok { switch strings.ToLower(strings.TrimSpace(value)) { case "true", "1", "yes", "on": *target = true case "false", "0", "no", "off": *target = false } } } func isFile(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() }