@@ -0,0 +1,882 @@
|
||||
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("<?php\nreturn [\n")
|
||||
writeKV := func(key, value string) {
|
||||
b.WriteString(" '")
|
||||
b.WriteString(key)
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user