Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
@@ -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()
}