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,293 @@
package auth
import (
"bytes"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"image"
"image/color"
"image/draw"
"image/png"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"ymhut-box/server/feedback-mailer/internal/config"
)
const (
sessionCookie = "ymhut_feedback_session"
captchaTTL = 5 * time.Minute
sessionTTL = 12 * time.Hour
)
type Service struct {
cfg *config.Config
mu sync.Mutex
captchas map[string]captchaEntry
sessions map[string]sessionEntry
}
type captchaEntry struct {
Answer string
ExpiresAt time.Time
}
type sessionEntry struct {
ExpiresAt time.Time
CSRFToken string
}
type Captcha struct {
ID string
ImagePNG []byte
}
func NewService(cfg *config.Config) *Service {
return &Service{
cfg: cfg,
captchas: map[string]captchaEntry{},
sessions: map[string]sessionEntry{},
}
}
func (s *Service) NewCaptcha() (Captcha, error) {
answer := randomDigits(5)
id := randomToken(16)
imageBytes, err := renderCaptcha(answer)
if err != nil {
return Captcha{}, err
}
s.mu.Lock()
s.cleanupLocked()
s.captchas[id] = captchaEntry{Answer: answer, ExpiresAt: time.Now().Add(captchaTTL)}
s.mu.Unlock()
return Captcha{ID: id, ImagePNG: imageBytes}, nil
}
func (s *Service) Login(password, captchaID, captchaAnswer string) (string, string, bool) {
if !s.consumeCaptcha(captchaID, captchaAnswer) {
return "", "", false
}
if !s.VerifyPassword(password) {
return "", "", false
}
sessionID := randomToken(32)
csrf := randomToken(32)
s.mu.Lock()
s.cleanupLocked()
s.sessions[sessionID] = sessionEntry{ExpiresAt: time.Now().Add(sessionTTL), CSRFToken: csrf}
s.mu.Unlock()
return sessionID, csrf, true
}
func (s *Service) Logout(c *gin.Context) {
sessionID, _ := c.Cookie(sessionCookie)
s.mu.Lock()
delete(s.sessions, sessionID)
s.mu.Unlock()
clearCookie(c)
}
func (s *Service) RequireAuth(c *gin.Context) {
sessionID, err := c.Cookie(sessionCookie)
if err != nil || sessionID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
c.Abort()
return
}
csrf, ok := s.SessionCSRF(sessionID)
if !ok {
clearCookie(c)
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "UNAUTHORIZED", "message": "Login required"})
c.Abort()
return
}
c.Set("csrf", csrf)
c.Next()
}
func (s *Service) RequireCSRF(c *gin.Context) {
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
c.Next()
return
}
expected, _ := c.Get("csrf")
actual := c.GetHeader("X-CSRF-Token")
if expected == nil || actual == "" || subtle.ConstantTimeCompare([]byte(expected.(string)), []byte(actual)) != 1 {
c.JSON(http.StatusForbidden, gin.H{"ok": false, "error": "CSRF_INVALID", "message": "Invalid CSRF token"})
c.Abort()
return
}
c.Next()
}
func (s *Service) SetSessionCookie(c *gin.Context, sessionID string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, sessionID, int(sessionTTL.Seconds()), "/", "", false, true)
}
func (s *Service) SessionCSRF(sessionID string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.cleanupLocked()
session, ok := s.sessions[sessionID]
if !ok {
return "", false
}
return session.CSRFToken, true
}
func (s *Service) VerifyPassword(password string) bool {
password = strings.TrimSpace(password)
if password == "" {
return false
}
hash := strings.TrimSpace(s.cfg.AdminPasswordHash)
if hash != "" && verifyBcrypt(hash, password) {
return true
}
plain := strings.TrimSpace(s.cfg.AdminPassword)
return plain != "" && subtle.ConstantTimeCompare([]byte(plain), []byte(password)) == 1
}
func (s *Service) consumeCaptcha(id, answer string) bool {
id = strings.TrimSpace(id)
answer = strings.TrimSpace(answer)
s.mu.Lock()
defer s.mu.Unlock()
s.cleanupLocked()
entry, ok := s.captchas[id]
if ok {
delete(s.captchas, id)
}
if !ok || time.Now().After(entry.ExpiresAt) {
return false
}
return subtle.ConstantTimeCompare([]byte(strings.ToLower(entry.Answer)), []byte(strings.ToLower(answer))) == 1
}
func (s *Service) cleanupLocked() {
now := time.Now()
for id, entry := range s.captchas {
if now.After(entry.ExpiresAt) {
delete(s.captchas, id)
}
}
for id, entry := range s.sessions {
if now.After(entry.ExpiresAt) {
delete(s.sessions, id)
}
}
}
func verifyBcrypt(hash, password string) bool {
candidates := []string{hash}
if strings.HasPrefix(hash, "$2y$") {
candidates = append(candidates, "$2a$"+strings.TrimPrefix(hash, "$2y$"))
}
for _, candidate := range candidates {
if bcrypt.CompareHashAndPassword([]byte(candidate), []byte(password)) == nil {
return true
}
}
return false
}
func clearCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(sessionCookie, "", -1, "/", "", false, true)
}
func randomDigits(count int) string {
data := make([]byte, count)
if _, err := rand.Read(data); err != nil {
return "12345"
}
var builder strings.Builder
for _, value := range data {
builder.WriteByte('0' + value%10)
}
return builder.String()
}
func randomToken(bytesLen int) string {
data := make([]byte, bytesLen)
if _, err := rand.Read(data); err != nil {
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
}
return hex.EncodeToString(data)
}
func renderCaptcha(answer string) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, 180, 64))
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{245, 248, 252, 255}}, image.Point{}, draw.Src)
for i := 0; i < 24; i++ {
x := (i*37 + 13) % 180
y := (i*19 + 7) % 64
img.Set(x, y, color.RGBA{102, 120, 145, 255})
}
for index, digit := range answer {
drawDigit(img, int(digit-'0'), 18+index*32, 13, color.RGBA{28, 72, 130, 255})
}
var buffer bytes.Buffer
if err := png.Encode(&buffer, img); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
var segments = [10][7]bool{
{true, true, true, true, true, true, false},
{false, true, true, false, false, false, false},
{true, true, false, true, true, false, true},
{true, true, true, true, false, false, true},
{false, true, true, false, false, true, true},
{true, false, true, true, false, true, true},
{true, false, true, true, true, true, true},
{true, true, true, false, false, false, false},
{true, true, true, true, true, true, true},
{true, true, true, true, false, true, true},
}
func drawDigit(img *image.RGBA, digit, x, y int, col color.Color) {
if digit < 0 || digit > 9 {
return
}
thick := 4
width := 22
height := 36
drawSegment := func(rect image.Rectangle) {
draw.Draw(img, rect, &image.Uniform{col}, image.Point{}, draw.Src)
}
if segments[digit][0] {
drawSegment(image.Rect(x+thick, y, x+width-thick, y+thick))
}
if segments[digit][1] {
drawSegment(image.Rect(x+width-thick, y+thick, x+width, y+height/2))
}
if segments[digit][2] {
drawSegment(image.Rect(x+width-thick, y+height/2, x+width, y+height-thick))
}
if segments[digit][3] {
drawSegment(image.Rect(x+thick, y+height-thick, x+width-thick, y+height))
}
if segments[digit][4] {
drawSegment(image.Rect(x, y+height/2, x+thick, y+height-thick))
}
if segments[digit][5] {
drawSegment(image.Rect(x, y+thick, x+thick, y+height/2))
}
if segments[digit][6] {
drawSegment(image.Rect(x+thick, y+height/2-thick/2, x+width-thick, y+height/2+thick/2))
}
}
@@ -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()
}
@@ -0,0 +1,250 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadJSONConfig(t *testing.T) {
dir := t.TempDir()
content := `{
"listen": ":9090",
"admin_password": "secret",
"client_signature_key": "client-key",
"package_encryption_key": "package-key",
"timestamp_window_seconds": 90,
"max_request_bytes": 123,
"max_package_bytes": 456,
"storage_dir": "./data",
"database_path": "./data/feedback.sqlite",
"mail": {
"host": "smtp.example.com",
"port": 587,
"secure": "starttls",
"username": "u",
"password": "p",
"from_address": "from@example.com",
"from_name": "Feedback",
"developer_address": "dev@example.com",
"timeout_seconds": 7
}
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Listen != ":9090" || cfg.AdminPassword != "secret" || cfg.ClientSignatureKey != "client-key" {
t.Fatalf("unexpected top-level config: %+v", cfg)
}
if cfg.StorageDir != filepath.Join(dir, "data") {
t.Fatalf("storage dir was not normalized: %q", cfg.StorageDir)
}
if cfg.Mail.Host != "smtp.example.com" || cfg.Mail.Port != 587 || cfg.Mail.Secure != "starttls" {
t.Fatalf("unexpected mail config: %+v", cfg.Mail)
}
}
func TestConfigTxtTakesPriorityOverJSON(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"admin_password":"json-secret"}`), 0o600); err != nil {
t.Fatal(err)
}
content := `<?php
$adminPassword = 'txt-secret';
return [
'listen' => ':9191',
'admin_password' => $adminPassword,
'storage_dir' => __DIR__ . '/storage',
'database_path' => __DIR__ . '/storage/feedback.sqlite',
];`
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.AdminPassword != "txt-secret" || cfg.Listen != ":9191" {
t.Fatalf("config.txt should win over config.json, got password=%q listen=%q", cfg.AdminPassword, cfg.Listen)
}
}
func TestLoadConfigTxtLegacyArrayConfig(t *testing.T) {
dir := t.TempDir()
content := `<?php
$adminPassword = 'legacy-secret';
return [
'listen' => ':7070',
'admin_password_hash' => '',
'admin_password' => $adminPassword,
'client_signature_key' => 'legacy-client',
'package_encryption_key' => 'legacy-package',
'timestamp_window_seconds' => 120,
'max_request_bytes' => 12 * 1024 * 1024,
'max_package_bytes' => 10 * 1024 * 1024,
'storage_dir' => __DIR__ . '/storage',
'database_path' => __DIR__ . '/storage/feedback.sqlite',
'mail' => [
'host' => 'mail.example.com',
'port' => 465,
'secure' => 'ssl',
'username' => 'sender@example.com',
'password' => 'mail-secret',
'from_address' => 'sender@example.com',
'from_name' => 'YMhut Box Feedback',
'developer_address' => 'developer@example.com',
'timeout_seconds' => 20,
],
];`
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Listen != ":7070" || cfg.AdminPassword != "legacy-secret" || cfg.ClientSignatureKey != "legacy-client" || cfg.PackageEncryptionKey != "legacy-package" {
t.Fatalf("unexpected legacy config: %+v", cfg)
}
if cfg.MaxRequestBytes != 12*1024*1024 || cfg.MaxPackageBytes != 10*1024*1024 {
t.Fatalf("legacy integer expressions were not evaluated: %+v", cfg)
}
if cfg.StorageDir != filepath.Join(dir, "storage") || cfg.DatabasePath != filepath.Join(dir, "storage", "feedback.sqlite") {
t.Fatalf("legacy __DIR__ paths were not normalized: %q %q", cfg.StorageDir, cfg.DatabasePath)
}
if cfg.Mail.Password != "mail-secret" || cfg.Mail.DeveloperAddress != "developer@example.com" {
t.Fatalf("unexpected legacy mail config: %+v", cfg.Mail)
}
}
func TestLoadConfigTxtEnhancedSettings(t *testing.T) {
dir := t.TempDir()
content := `<?php
return [
'listen' => ':7071',
'admin_password' => 'secret',
'storage_dir' => __DIR__ . '/storage',
'database_path' => __DIR__ . '/storage/feedback.sqlite',
'submission_per_minute' => 9,
'max_zip_files' => 12,
'max_decompressed_bytes' => 1024 * 1024,
'backup_dir' => __DIR__ . '/storage/backups',
'webhooks' => [
[
'name' => 'ops',
'url' => 'https://example.com/hook',
'secret' => 'hook-secret',
'enabled' => true,
'events' => ['feedback.created', 'mail.failed'],
'timeout_seconds' => 3,
'max_retries' => 1,
],
],
];`
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.RateLimit.SubmissionPerMinute != 9 || cfg.UploadGuard.MaxZipFiles != 12 || cfg.UploadGuard.MaxDecompressedBytes != 1024*1024 {
t.Fatalf("enhanced settings were not parsed: %+v", cfg)
}
if cfg.Backup.Dir != filepath.Join(dir, "storage", "backups") {
t.Fatalf("backup dir was not normalized: %q", cfg.Backup.Dir)
}
if len(cfg.Webhooks) != 1 || cfg.Webhooks[0].Name != "ops" || cfg.Webhooks[0].Events[1] != "mail.failed" || cfg.Webhooks[0].MaxRetries != 1 {
t.Fatalf("webhooks were not parsed: %+v", cfg.Webhooks)
}
}
func TestLoadConfigTxtDatabaseSettings(t *testing.T) {
dir := t.TempDir()
content := `<?php
return [
'storage_dir' => __DIR__ . '/storage',
'database_path' => __DIR__ . '/storage/feedback.sqlite',
'database_provider' => 'postgres',
'database_host' => 'db.example.com',
'database_port' => 5433,
'database_name' => 'feedback',
'database_user' => 'feedback_user',
'database_password' => 'db-secret',
'database_ssl_mode' => 'require',
'database_failover_enabled' => true,
'database_sync_enabled' => true,
'database_sync_interval_seconds' => 60,
'database_sync_batch_size' => 25,
];`
if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Database.Provider != "postgres" || cfg.Database.Host != "db.example.com" || cfg.Database.Port != 5433 || cfg.Database.Password != "db-secret" {
t.Fatalf("database config was not parsed: %+v", cfg.Database)
}
if cfg.Database.SQLitePath != filepath.Join(dir, "storage", "feedback.sqlite") || cfg.DatabasePath != cfg.Database.SQLitePath {
t.Fatalf("sqlite path compatibility failed: %q %q", cfg.Database.SQLitePath, cfg.DatabasePath)
}
if !cfg.Database.FailoverEnabled || !cfg.Database.Sync.Enabled || cfg.Database.Sync.IntervalSeconds != 60 || cfg.Database.Sync.BatchSize != 25 {
t.Fatalf("database sync settings were not parsed: %+v", cfg.Database)
}
}
func TestSaveConfigCreatesReloadableConfigTxt(t *testing.T) {
dir := t.TempDir()
cfg := Default(dir)
cfg.Listen = ":9099"
cfg.Database.Provider = "mysql"
cfg.Database.Host = "mysql.example.com"
cfg.Database.Name = "feedback"
cfg.Database.User = "feedback_user"
cfg.Database.Password = "mysql-secret"
cfg.Webhooks = []WebhookConfig{{Name: "ops", URL: "https://example.com/hook", Secret: "hook-secret", Enabled: true, Events: []string{"feedback.created"}, TimeoutSeconds: 4, MaxRetries: 2}}
if err := Save(cfg); err != nil {
t.Fatal(err)
}
saved, err := os.ReadFile(filepath.Join(dir, "config.txt"))
if err != nil {
t.Fatal(err)
}
text := string(saved)
if strings.Contains(text, dir) {
t.Fatalf("saved config should not contain deploy-root absolute paths: %s", text)
}
for _, expected := range []string{
"'storage_dir' => 'storage'",
"'database_path' => 'storage/feedback.sqlite'",
"'database_sqlite_path' => 'storage/feedback.sqlite'",
"'backup_dir' => 'storage/backups'",
} {
if !strings.Contains(text, expected) {
t.Fatalf("saved config is missing portable path %q: %s", expected, text)
}
}
loaded, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if loaded.Listen != ":9099" || loaded.Database.Provider != "mysql" || loaded.Database.Password != "mysql-secret" {
t.Fatalf("saved config did not reload: %+v", loaded.Database)
}
if len(loaded.Webhooks) != 1 || loaded.Webhooks[0].Secret != "hook-secret" {
t.Fatalf("saved webhooks did not reload: %+v", loaded.Webhooks)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
package db
import (
"database/sql"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
mysql "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
"ymhut-box/server/feedback-mailer/internal/config"
)
func TestOpenMigratesLegacyFeedbackColumns(t *testing.T) {
dir := t.TempDir()
storage := filepath.Join(dir, "storage")
if err := os.MkdirAll(storage, 0o750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(storage, "feedback.sqlite")
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = conn.Exec(`CREATE TABLE feedbacks (
code TEXT PRIMARY KEY,
received_at TEXT NOT NULL,
title TEXT NOT NULL,
type TEXT NOT NULL,
severity TEXT NOT NULL,
contact TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL,
package_path TEXT NOT NULL,
package_sha256 TEXT NOT NULL,
remote_addr TEXT NOT NULL,
summary_text TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if closeErr := conn.Close(); closeErr != nil {
t.Fatal(closeErr)
}
if err != nil {
t.Fatal(err)
}
cfg := config.Default(dir)
cfg.StorageDir = storage
cfg.DatabasePath = dbPath
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
for _, column := range []string{"public_reply", "included_files", "mail_sent", "encrypted_package_path", "plain_package_sha256", "assignee", "due_at", "sla_level", "source_channel", "risk_score", "resolution"} {
if !hasColumn(t, store.db, "feedbacks", column) {
t.Fatalf("expected migrated column %q", column)
}
}
for _, table := range []string{"feedback_comments", "feedback_tags", "audit_logs", "webhook_deliveries"} {
if !hasTable(t, store.db, table) {
t.Fatalf("expected migrated table %q", table)
}
}
if mode := store.WALMode(); mode != "wal" {
t.Fatalf("expected wal mode, got %q", mode)
}
}
func TestTicketExtensionsRoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
record := FeedbackRecord{
Code: "FB-20260606-ABC123",
ReceivedAt: Now(),
Title: "Crash",
Type: "issue",
Severity: "major",
Contact: "dev@example.com",
Body: "Steps",
Status: "new",
StatusDetail: "received",
PackagePath: "a.zip",
EncryptedPackagePath: "a.ymfb",
PackageSha256: strings.Repeat("a", 64),
PlainPackageSha256: strings.Repeat("b", 64),
RemoteAddr: "127.0.0.1",
SummaryText: "summary",
IncludedFiles: "feedback.json",
UpdatedAt: Now(),
LastActivityAt: Now(),
Tags: []string{"crash", "UI"},
}
if err := store.InsertFeedback(record); err != nil {
t.Fatal(err)
}
if err := store.UpdateFeedback(record.Code, FeedbackUpdate{
Status: "investigating",
Category: "issue",
Priority: "major",
StatusDetail: "checking",
Assignee: "alice",
SLALevel: "elevated",
Note: "internal",
PublicReply: "reply",
Actor: "alice",
Tags: []string{"crash", "priority"},
}); err != nil {
t.Fatal(err)
}
if _, err := store.InsertComment(FeedbackComment{FeedbackCode: record.Code, Author: "alice", Body: "comment", Internal: true}); err != nil {
t.Fatal(err)
}
if err := store.InsertAudit(AuditLog{Actor: "alice", Type: "feedback.updated", Target: record.Code, Message: "updated"}); err != nil {
t.Fatal(err)
}
if _, err := store.InsertWebhookDelivery(WebhookDelivery{WebhookName: "ops", Event: "feedback.updated", Status: "pending"}); err != nil {
t.Fatal(err)
}
detail, err := store.GetFeedbackDetail(record.Code)
if err != nil {
t.Fatal(err)
}
if detail.Assignee != "alice" || detail.SLALevel != "elevated" || len(detail.Comments) != 1 || len(detail.Tags) != 2 {
t.Fatalf("unexpected detail: %+v", detail)
}
page, err := store.ListFeedbacks(1, 20, FeedbackFilters{Assignee: "alice", Tag: "priority", SLA: "elevated"})
if err != nil {
t.Fatal(err)
}
if page.Total != 1 {
t.Fatalf("expected filtered ticket, got %+v", page)
}
}
func TestApplyDatabaseConfigSwitchesSQLitePath(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
next := cfg.Database
next.Provider = "sqlite"
next.SQLitePath = filepath.Join(dir, "storage", "next.sqlite")
if err := store.ApplyDatabaseConfig(next); err != nil {
t.Fatal(err)
}
if store.Status().ActiveProvider != "sqlite" {
t.Fatalf("expected sqlite active provider, got %+v", store.Status())
}
if _, err := os.Stat(next.SQLitePath); err != nil {
t.Fatalf("expected new sqlite database at %s: %v", next.SQLitePath, err)
}
if !hasTable(t, store.DB(), "feedbacks") {
t.Fatal("expected migrated feedbacks table on switched sqlite database")
}
}
func TestDatabaseDSNEncodesRemoteCredentials(t *testing.T) {
dir := t.TempDir()
sqliteDSN, err := databaseDSN(config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: "storage/feedback.sqlite",
}, dir)
if err != nil {
t.Fatal(err)
}
if sqliteDSN != filepath.Join(dir, "storage", "feedback.sqlite") {
t.Fatalf("relative sqlite path should resolve from service root, got %q", sqliteDSN)
}
mysqlDSN, err := databaseDSN(config.DatabaseConfig{
Provider: "mysql",
Host: "db.example.com",
Port: 3307,
Name: "feedback_db",
User: "feedback_user",
Password: "p@ss/word",
}, dir)
if err != nil {
t.Fatal(err)
}
parsedMySQL, err := mysql.ParseDSN(mysqlDSN)
if err != nil {
t.Fatal(err)
}
if parsedMySQL.User != "feedback_user" || parsedMySQL.Passwd != "p@ss/word" || parsedMySQL.DBName != "feedback_db" || parsedMySQL.Addr != "db.example.com:3307" {
t.Fatalf("mysql DSN did not preserve settings: %+v", parsedMySQL)
}
postgresDSN, err := databaseDSN(config.DatabaseConfig{
Provider: "postgres",
Host: "pg.example.com",
Port: 5433,
Name: "feedback/db",
User: "feedback:user",
Password: "p@ss/word",
SSLMode: "require",
}, dir)
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(postgresDSN)
if err != nil {
t.Fatal(err)
}
if parsed.Scheme != "postgres" || parsed.Host != "pg.example.com:5433" || parsed.Query().Get("sslmode") != "require" {
t.Fatalf("postgres DSN was not safely formatted: %s", postgresDSN)
}
if password, ok := parsed.User.Password(); !ok || password != "p@ss/word" || parsed.User.Username() != "feedback:user" {
t.Fatalf("postgres credentials were not preserved: %s", postgresDSN)
}
}
func hasColumn(t *testing.T, conn *sql.DB, table, column string) bool {
t.Helper()
rows, err := conn.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull int
var dflt sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
t.Fatal(err)
}
if name == column {
return true
}
}
return false
}
func hasTable(t *testing.T, conn *sql.DB, table string) bool {
t.Helper()
row := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table)
var name string
if err := row.Scan(&name); err != nil {
return false
}
return name == table
}
@@ -0,0 +1,263 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
mysql "github.com/go-sql-driver/mysql"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"ymhut-box/server/feedback-mailer/internal/config"
)
type dialect struct {
name string
driverName string
}
func dialectFor(provider string) dialect {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "mysql":
return dialect{name: "mysql", driverName: "mysql"}
case "postgres", "pgsql":
return dialect{name: "postgres", driverName: "pgx"}
default:
return dialect{name: "sqlite", driverName: "sqlite"}
}
}
func (d dialect) rebind(query string) string {
if d.name != "postgres" {
return query
}
var builder strings.Builder
index := 1
inSingle := false
for i := 0; i < len(query); i++ {
ch := query[i]
if ch == '\'' {
inSingle = !inSingle
builder.WriteByte(ch)
continue
}
if ch == '?' && !inSingle {
builder.WriteByte('$')
builder.WriteString(strconv.Itoa(index))
index++
continue
}
builder.WriteByte(ch)
}
return builder.String()
}
func (d dialect) boolValue(value bool) int {
if value {
return 1
}
return 0
}
func (d dialect) insertIgnore(table string, columns, conflict []string) string {
placeholders := make([]string, len(columns))
for i := range placeholders {
placeholders[i] = "?"
}
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
switch d.name {
case "mysql":
return strings.Replace(base, "INSERT INTO", "INSERT IGNORE INTO", 1)
case "postgres":
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO NOTHING"
default:
return strings.Replace(base, "INSERT INTO", "INSERT OR IGNORE INTO", 1)
}
}
func (d dialect) upsert(table string, columns, conflict []string) string {
placeholders := make([]string, len(columns))
for i := range placeholders {
placeholders[i] = "?"
}
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
updateColumns := make([]string, 0, len(columns))
conflictSet := map[string]bool{}
for _, column := range conflict {
conflictSet[column] = true
}
for _, column := range columns {
if conflictSet[column] {
continue
}
switch d.name {
case "mysql":
updateColumns = append(updateColumns, fmt.Sprintf("%s = VALUES(%s)", column, column))
case "postgres":
updateColumns = append(updateColumns, fmt.Sprintf("%s = EXCLUDED.%s", column, column))
default:
updateColumns = append(updateColumns, fmt.Sprintf("%s = excluded.%s", column, column))
}
}
if len(updateColumns) == 0 {
return d.insertIgnore(table, columns, conflict)
}
switch d.name {
case "mysql":
return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updateColumns, ", ")
case "postgres":
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updateColumns, ", ")
default:
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updateColumns, ", ")
}
}
func (d dialect) textType() string {
return "TEXT"
}
func (d dialect) idType() string {
switch d.name {
case "mysql":
return "BIGINT PRIMARY KEY AUTO_INCREMENT"
case "postgres":
return "BIGSERIAL PRIMARY KEY"
default:
return "INTEGER PRIMARY KEY AUTOINCREMENT"
}
}
func (d dialect) columnDefault(def string) string {
def = strings.ReplaceAll(def, `DEFAULT ""`, `DEFAULT ''`)
if d.name == "mysql" {
def = strings.ReplaceAll(def, `TEXT NOT NULL DEFAULT ''`, `VARCHAR(3000) NOT NULL DEFAULT ''`)
}
return def
}
func openSQLDatabase(cfg config.DatabaseConfig, baseDir string) (*sql.DB, dialect, error) {
d := dialectFor(cfg.Provider)
dsn, err := databaseDSN(cfg, baseDir)
if err != nil {
return nil, d, err
}
if d.name == "sqlite" && !strings.HasPrefix(strings.ToLower(dsn), "file:") {
if err := os.MkdirAll(filepath.Dir(dsn), 0o750); err != nil {
return nil, d, err
}
}
conn, err := sql.Open(d.driverName, dsn)
if err != nil {
return nil, d, err
}
conn.SetMaxOpenConns(cfg.MaxOpenConns)
conn.SetMaxIdleConns(cfg.MaxIdleConns)
conn.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.PingContext(ctx); err != nil {
_ = conn.Close()
return nil, d, err
}
return conn, d, nil
}
func databaseDSN(cfg config.DatabaseConfig, baseDir string) (string, error) {
provider := strings.ToLower(strings.TrimSpace(cfg.Provider))
switch provider {
case "", "sqlite":
path := strings.TrimSpace(cfg.SQLitePath)
if path == "" {
path = filepath.Join(baseDir, "storage", "feedback.sqlite")
}
if strings.HasPrefix(strings.ToLower(path), "file:") {
return path, nil
}
if !filepath.IsAbs(path) && !strings.HasPrefix(strings.ToLower(path), "file:") {
path = filepath.Join(baseDir, path)
}
return filepath.Clean(path), nil
case "mysql":
if strings.TrimSpace(cfg.DSN) != "" {
return cfg.DSN, nil
}
if cfg.Host == "" || cfg.Name == "" || cfg.User == "" {
return "", errors.New("mysql host, name and user are required")
}
host := cfg.Host
if cfg.Port > 0 {
host = host + ":" + strconv.Itoa(cfg.Port)
}
mysqlCfg := mysql.NewConfig()
mysqlCfg.User = cfg.User
mysqlCfg.Passwd = cfg.Password
mysqlCfg.Net = "tcp"
mysqlCfg.Addr = host
mysqlCfg.DBName = cfg.Name
mysqlCfg.ParseTime = true
mysqlCfg.Loc = time.Local
mysqlCfg.Params = map[string]string{"charset": "utf8mb4"}
if cfg.SSLMode != "" && cfg.SSLMode != "disable" {
mysqlCfg.TLSConfig = cfg.SSLMode
}
return mysqlCfg.FormatDSN(), nil
case "postgres", "pgsql":
if strings.TrimSpace(cfg.DSN) != "" {
return cfg.DSN, nil
}
if cfg.Host == "" || cfg.Name == "" || cfg.User == "" {
return "", errors.New("postgres host, name and user are required")
}
host := cfg.Host
if cfg.Port > 0 {
host = host + ":" + strconv.Itoa(cfg.Port)
}
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(cfg.User, cfg.Password),
Host: host,
Path: "/" + cfg.Name,
}
params := url.Values{}
params.Set("sslmode", defaultString(cfg.SSLMode, "disable"))
u.RawQuery = params.Encode()
return u.String(), nil
default:
return "", fmt.Errorf("unsupported database provider %q", cfg.Provider)
}
}
func TestDatabase(cfg config.DatabaseConfig, baseDir string) error {
conn, d, err := openSQLDatabase(cfg, baseDir)
if err != nil {
return err
}
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.PingContext(ctx); err != nil {
return err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
create := `CREATE TEMPORARY TABLE ymhut_connection_test (id INTEGER)`
if d.name == "postgres" {
create = `CREATE TEMP TABLE ymhut_connection_test (id INTEGER)`
}
if _, err := tx.ExecContext(ctx, d.rebind(create)); err != nil {
_ = tx.Rollback()
return err
}
_ = tx.Rollback()
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package db
import (
"database/sql"
"fmt"
"strings"
)
type SyncResult struct {
Direction string `json:"direction"`
Tables map[string]int `json:"tables"`
FinishedAt string `json:"finishedAt"`
}
type tableSpec struct {
Name string
Columns []string
Conflict []string
}
var syncTables = []tableSpec{
{"feedbacks", []string{"code", "received_at", "title", "type", "severity", "category", "priority", "contact", "body", "status", "status_detail", "note", "public_reply", "handled_by", "assignee", "due_at", "resolved_at", "archived_at", "sla_level", "source_channel", "risk_score", "resolution", "package_path", "encrypted_package_path", "package_sha256", "plain_package_sha256", "remote_addr", "summary_text", "included_files", "mail_sent", "updated_at", "last_activity_at"}, []string{"code"}},
{"mail_records", []string{"id", "feedback_code", "kind", "status", "to_address", "subject", "plain_body", "html_body", "attachment_path", "attachment_name", "error_message", "created_at", "sent_at"}, []string{"id"}},
{"feedback_events", []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}, []string{"id"}},
{"feedback_comments", []string{"id", "feedback_code", "author", "body", "internal", "created_at"}, []string{"id"}},
{"feedback_tags", []string{"feedback_code", "tag", "created_at"}, []string{"feedback_code", "tag"}},
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
{"webhook_deliveries", []string{"id", "webhook_name", "event", "status", "attempts", "response_code", "error_message", "payload_sha256", "created_at", "finished_at"}, []string{"id"}},
}
func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
s.mu.RLock()
remote := s.remoteDB
remoteDialect := s.remoteDialect
local := s.localDB
localDialect := s.localDialect
s.mu.RUnlock()
if remote == nil {
return SyncResult{}, fmt.Errorf("remote database is not configured")
}
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
s.setSyncStatus(result, err)
return result, err
}
func (s *Store) SyncNow() (SyncResult, error) {
return s.syncRemoteToSQLite()
}
func (s *Store) syncRemoteToSQLite() (SyncResult, error) {
s.mu.RLock()
remote := s.remoteDB
remoteDialect := s.remoteDialect
local := s.localDB
localDialect := s.localDialect
s.mu.RUnlock()
if remote == nil {
return SyncResult{}, nil
}
result, err := copyAllTables(remote, remoteDialect, local, localDialect, "remote_to_sqlite")
s.setSyncStatus(result, err)
return result, err
}
func (s *Store) setSyncStatus(result SyncResult, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if err != nil {
s.status.LastSyncError = err.Error()
return
}
if result.FinishedAt != "" {
s.status.LastSyncAt = result.FinishedAt
}
s.status.LastSyncError = ""
}
func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) {
result := SyncResult{Direction: direction, Tables: map[string]int{}, FinishedAt: Now()}
for _, table := range syncTables {
copied, err := copyTable(src, srcDialect, dst, dstDialect, table)
if err != nil {
return result, err
}
result.Tables[table.Name] = copied
}
return result, nil
}
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
selectSQL := "SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name
rows, err := src.Query(srcDialect.rebind(selectSQL))
if err != nil {
return 0, err
}
defer rows.Close()
insertSQL := dstDialect.rebind(dstDialect.upsert(spec.Name, spec.Columns, spec.Conflict))
count := 0
for rows.Next() {
values := make([]any, len(spec.Columns))
ptrs := make([]any, len(spec.Columns))
for index := range values {
ptrs[index] = &values[index]
}
if err := rows.Scan(ptrs...); err != nil {
return count, err
}
for index, value := range values {
if bytes, ok := value.([]byte); ok {
values[index] = string(bytes)
}
}
if _, err := dst.Exec(insertSQL, values...); err != nil {
return count, err
}
count++
}
return count, rows.Err()
}
@@ -0,0 +1,800 @@
package feedback
import (
"archive/zip"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"ymhut-box/server/feedback-mailer/internal/config"
"ymhut-box/server/feedback-mailer/internal/db"
feedbackmail "ymhut-box/server/feedback-mailer/internal/mail"
"ymhut-box/server/feedback-mailer/internal/webhook"
)
const (
ErrorMethodNotAllowed = "METHOD_NOT_ALLOWED"
ErrorTooLarge = "TOO_LARGE"
ErrorMissingField = "MISSING_FIELD"
ErrorInvalidPayload = "INVALID_PAYLOAD"
ErrorInvalidTimestamp = "INVALID_TIMESTAMP"
ErrorInvalidSignature = "INVALID_SIGNATURE"
ErrorInvalidPackage = "INVALID_PACKAGE"
ErrorInvalidEncryptedPackage = "INVALID_ENCRYPTED_PACKAGE"
ErrorDecryptFailed = "DECRYPT_FAILED"
ErrorHashMismatch = "HASH_MISMATCH"
ErrorMailFailed = "MAIL_FAILED"
ErrorServerConfig = "SERVER_CONFIG"
ErrorNotFound = "NOT_FOUND"
PackageMagic = "YMHUTFB1"
)
var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`)
type Service struct {
cfg *config.Config
store *db.Store
hooks *webhook.Dispatcher
}
type StatusPayload struct {
OK bool `json:"ok"`
Code string `json:"code"`
Status string `json:"status"`
StatusLabel string `json:"statusLabel"`
StatusDetail string `json:"statusDetail"`
Category string `json:"category"`
Priority string `json:"priority"`
HasReply bool `json:"hasReply"`
Reply string `json:"reply"`
ReceivedAt string `json:"receivedAt"`
UpdatedAt string `json:"updatedAt"`
MailSent bool `json:"mailSent"`
Duplicate bool `json:"duplicate,omitempty"`
}
type submissionPayload struct {
FeedbackCode string `json:"feedbackCode"`
Title string `json:"title"`
Type string `json:"type"`
Severity string `json:"severity"`
Contact string `json:"contact"`
BodyLength int `json:"bodyLength"`
PackageEncrypted bool `json:"packageEncrypted"`
Encryption string `json:"encryption"`
PackageBytes int64 `json:"packageBytes"`
PackageSha256 string `json:"packageSha256"`
PlainPackageBytes int64 `json:"plainPackageBytes"`
PlainPackageSha256 string `json:"plainPackageSha256"`
CreatedAt json.RawMessage `json:"createdAt"`
}
type packageInfo struct {
Request map[string]any
Summary string
Files []string
}
func NewService(cfg *config.Config, store *db.Store, hooks *webhook.Dispatcher) *Service {
return &Service{cfg: cfg, store: store, hooks: hooks}
}
func (s *Service) HandleSubmission(c *gin.Context) {
if c.Request.Method != http.MethodPost {
Fail(c, ErrorMethodNotAllowed, http.StatusMethodNotAllowed, "POST required")
return
}
if s.cfg.MaxRequestBytes > 0 {
if c.Request.ContentLength > s.cfg.MaxRequestBytes {
Fail(c, ErrorTooLarge, http.StatusRequestEntityTooLarge, "Request is too large")
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, s.cfg.MaxRequestBytes)
}
payloadText, ok := requireForm(c, "payload")
if !ok {
return
}
timestamp, ok := requireForm(c, "timestamp")
if !ok {
return
}
nonce, ok := requireForm(c, "nonce")
if !ok {
return
}
packageSha256, ok := requireForm(c, "packageSha256")
if !ok {
return
}
signature, ok := requireForm(c, "signature")
if !ok {
return
}
var payload submissionPayload
if err := json.Unmarshal([]byte(payloadText), &payload); err != nil {
Fail(c, ErrorInvalidPayload, http.StatusBadRequest, "Invalid payload JSON")
return
}
if err := validatePayload(payloadText, payload); err != nil {
Fail(c, errCode(err), http.StatusBadRequest, err.Error())
return
}
if !validTimestamp(timestamp, s.cfg.TimestampWindowSeconds) {
Fail(c, ErrorInvalidTimestamp, http.StatusBadRequest, "Timestamp outside accepted window")
return
}
packageSha256 = strings.ToLower(strings.TrimSpace(packageSha256))
signature = strings.ToLower(strings.TrimSpace(signature))
if !isHexSHA256(packageSha256) {
Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Invalid package hash")
return
}
if s.cfg.ClientSignatureKey == "" {
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Missing client signature key")
return
}
expected := SignWithKey(s.cfg.ClientSignatureKey, timestamp, nonce, packageSha256, payloadText)
if !hmac.Equal([]byte(expected), []byte(signature)) {
Fail(c, ErrorInvalidSignature, http.StatusUnauthorized, "Invalid request signature")
return
}
code := NormalizeCode(payload.FeedbackCode)
if code == "" {
code = s.generateCode()
}
existing, err := s.store.FetchStatus(code)
if err != nil {
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Server error")
return
}
if existing != nil {
response := StatusFromRow(*existing)
response.Duplicate = true
c.JSON(http.StatusOK, response)
return
}
file, err := c.FormFile("package")
if err != nil {
Fail(c, ErrorMissingField, http.StatusBadRequest, "Missing package file")
return
}
data, err := readUploadedPackage(file, s.cfg.MaxPackageBytes)
if err != nil {
if errors.Is(err, errUploadTooLarge) {
Fail(c, ErrorTooLarge, http.StatusRequestEntityTooLarge, "Feedback package is too large")
} else {
Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Package upload failed")
}
return
}
if !bytes.HasPrefix(data, []byte(PackageMagic)) {
Fail(c, ErrorInvalidEncryptedPackage, http.StatusBadRequest, "Encrypted package format is invalid")
return
}
actual := sha256Hex(data)
if !hmac.Equal([]byte(actual), []byte(packageSha256)) {
Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Package hash mismatch")
return
}
encryptedPath := filepath.Join(s.cfg.StorageDir, code+".ymfb")
packagePath := filepath.Join(s.cfg.StorageDir, code+".zip")
if err := os.WriteFile(encryptedPath, data, 0o640); err != nil {
Fail(c, ErrorInvalidPackage, http.StatusInternalServerError, "Unable to save package")
return
}
plain, err := DecryptPackage(data, s.cfg.PackageEncryptionKey)
if err != nil {
Fail(c, ErrorDecryptFailed, http.StatusBadRequest, "Unable to decrypt package")
return
}
if !isZipBytes(plain) {
Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Decrypted package is not a zip file")
return
}
if payload.PlainPackageSha256 != "" && isHexSHA256(payload.PlainPackageSha256) {
if !hmac.Equal([]byte(sha256Hex(plain)), []byte(strings.ToLower(payload.PlainPackageSha256))) {
Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Decrypted package hash mismatch")
return
}
}
info, err := ReadFeedbackPackageWithGuard(plain, s.cfg.UploadGuard)
if err != nil {
Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Unable to read feedback package")
return
}
if err := os.WriteFile(packagePath, plain, 0o640); err != nil {
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to write decrypted package")
return
}
record := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), c.ClientIP())
if err := s.store.InsertFeedback(record); err != nil {
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to retain feedback")
return
}
s.hooks.Dispatch("feedback.created", record)
message, err := feedbackmail.BuildFeedbackMessage(s.cfg, record, packagePath)
if err != nil {
s.hooks.Dispatch("mail.failed", gin.H{"feedbackCode": record.Code, "error": err.Error()})
Fail(c, ErrorMailFailed, http.StatusBadGateway, "Mail delivery failed; feedback record was retained")
return
}
mailID, err := s.store.InsertMail(db.MailRecord{
FeedbackCode: record.Code,
Kind: "feedback",
Status: "pending",
ToAddress: message.To,
Subject: message.Subject,
PlainBody: message.PlainBody,
HTMLBody: message.HTMLBody,
AttachmentPath: message.AttachmentPath,
AttachmentName: message.AttachmentName,
CreatedAt: db.Now(),
})
if err != nil {
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to retain mail record")
return
}
if err := feedbackmail.Send(s.cfg, message); err != nil {
_ = s.store.UpdateMailState(mailID, "failed", shortenPlain(err.Error(), 1000))
_ = s.store.UpdateFeedbackMailState(code, false)
s.hooks.Dispatch("mail.failed", gin.H{"feedbackCode": record.Code, "mailId": mailID, "error": err.Error()})
Fail(c, ErrorMailFailed, http.StatusBadGateway, "Mail delivery failed; feedback record was retained")
return
}
_ = s.store.UpdateMailState(mailID, "sent", "")
_ = s.store.UpdateFeedbackMailState(code, true)
c.JSON(http.StatusOK, gin.H{"ok": true, "code": code})
}
func (s *Service) HandleStatus(c *gin.Context) {
code := NormalizeCode(c.Query("code"))
if code == "" {
Fail(c, ErrorInvalidPayload, http.StatusBadRequest, "Invalid feedback code")
return
}
row, err := s.store.FetchStatus(code)
if err != nil {
Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Server error")
return
}
if row == nil {
Fail(c, ErrorNotFound, http.StatusNotFound, "Feedback not found")
return
}
c.JSON(http.StatusOK, StatusFromRow(*row))
}
func StatusFromRow(row db.StatusRow) StatusPayload {
reply := strings.TrimSpace(row.PublicReply)
return StatusPayload{
OK: true,
Code: row.Code,
Status: row.Status,
StatusLabel: StatusLabel(row.Status),
StatusDetail: row.StatusDetail,
Category: row.Category,
Priority: row.Priority,
HasReply: reply != "",
Reply: reply,
ReceivedAt: row.ReceivedAt,
UpdatedAt: row.UpdatedAt,
MailSent: row.MailSent,
}
}
func NormalizeCode(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
if feedbackCodePattern.MatchString(code) {
return code
}
return ""
}
func SignWithKey(key, timestamp, nonce, packageSha256, payload string) string {
material := timestamp + "\n" + nonce + "\n" + packageSha256 + "\n" + payload
mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write([]byte(material))
return hex.EncodeToString(mac.Sum(nil))
}
func validTimestamp(value string, windowSeconds int64) bool {
if !regexp.MustCompile(`^[0-9]{10,}$`).MatchString(value) {
return false
}
seconds, err := time.ParseDuration(value + "s")
if err != nil {
return false
}
delta := time.Now().Unix() - int64(seconds.Seconds())
if delta < 0 {
delta = -delta
}
return delta <= windowSeconds
}
func validatePayload(payloadText string, payload submissionPayload) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(payloadText), &raw); err != nil {
return codeError{code: ErrorInvalidPayload, message: "Invalid payload JSON"}
}
for _, field := range []string{"title", "type", "severity", "bodyLength", "packageBytes", "packageSha256", "plainPackageSha256", "createdAt"} {
if _, ok := raw[field]; !ok {
return fieldError("Payload missing field: " + field)
}
}
if payload.Title == "" {
return fieldError("Payload missing field: title")
}
if payload.Type == "" {
return fieldError("Payload missing field: type")
}
if payload.Severity == "" {
return fieldError("Payload missing field: severity")
}
if payload.BodyLength < 0 {
return fieldError("Payload missing field: bodyLength")
}
if payload.PackageBytes <= 0 {
return fieldError("Payload missing field: packageBytes")
}
if payload.PackageSha256 == "" {
return fieldError("Payload missing field: packageSha256")
}
if payload.PlainPackageSha256 == "" {
return fieldError("Payload missing field: plainPackageSha256")
}
if len(payload.CreatedAt) == 0 {
return fieldError("Payload missing field: createdAt")
}
if !payload.PackageEncrypted || payload.Encryption != PackageMagic {
return codeError{code: ErrorInvalidEncryptedPackage, message: "Encrypted package is required"}
}
return nil
}
type fieldError string
func (e fieldError) Error() string { return string(e) }
type codeError struct {
code string
message string
}
func (e codeError) Error() string { return e.message }
func errCode(err error) string {
var coded codeError
if errors.As(err, &coded) {
return coded.code
}
return ErrorMissingField
}
func requireForm(c *gin.Context, key string) (string, bool) {
value := strings.TrimSpace(c.PostForm(key))
if value == "" {
Fail(c, ErrorMissingField, http.StatusBadRequest, "Missing field: "+key)
return "", false
}
return value, true
}
var errUploadTooLarge = errors.New("upload too large")
func readUploadedPackage(file *multipart.FileHeader, maxBytes int64) ([]byte, error) {
if maxBytes > 0 && file.Size > maxBytes {
return nil, errUploadTooLarge
}
stream, err := file.Open()
if err != nil {
return nil, err
}
defer stream.Close()
limit := maxBytes + 1
if limit <= 1 {
limit = 10*1024*1024 + 1
}
data, err := io.ReadAll(io.LimitReader(stream, limit))
if err != nil {
return nil, err
}
if maxBytes > 0 && int64(len(data)) > maxBytes {
return nil, errUploadTooLarge
}
return data, nil
}
func DecryptPackage(data []byte, keyMaterial string) ([]byte, error) {
if len(data) < len(PackageMagic)+12+16 || !bytes.HasPrefix(data, []byte(PackageMagic)) {
return nil, errors.New("encrypted package format is invalid")
}
if keyMaterial == "" {
keyMaterial = "ymhut-box-feedback-package-v1"
}
key := sha256.Sum256([]byte(keyMaterial))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
offset := len(PackageMagic)
nonce := data[offset : offset+12]
offset += 12
tag := data[offset : offset+16]
offset += 16
ciphertext := data[offset:]
combined := make([]byte, 0, len(ciphertext)+len(tag))
combined = append(combined, ciphertext...)
combined = append(combined, tag...)
return gcm.Open(nil, nonce, combined, []byte(PackageMagic))
}
func ReadFeedbackPackage(plain []byte) (packageInfo, error) {
return ReadFeedbackPackageWithGuard(plain, config.Default(".").UploadGuard)
}
func ReadFeedbackPackageWithGuard(plain []byte, guard config.UploadGuardConfig) (packageInfo, error) {
reader, err := zip.NewReader(bytes.NewReader(plain), int64(len(plain)))
if err != nil {
return packageInfo{}, err
}
if guard.MaxZipFiles <= 0 {
guard.MaxZipFiles = 80
}
if guard.MaxDecompressedBytes <= 0 {
guard.MaxDecompressedBytes = 30 * 1024 * 1024
}
if guard.MaxSingleFileBytes <= 0 {
guard.MaxSingleFileBytes = 8 * 1024 * 1024
}
if guard.MaxCompressionRatio <= 0 {
guard.MaxCompressionRatio = 120
}
if guard.MaxReadableTextBytes <= 0 {
guard.MaxReadableTextBytes = 256 * 1024
}
files := []string{}
texts := map[string]string{}
var total uint64
for _, entry := range reader.File {
if entry.FileInfo().IsDir() {
continue
}
cleanName, err := safeZipName(entry.Name)
if err != nil {
return packageInfo{}, err
}
if len(files)+1 > guard.MaxZipFiles {
return packageInfo{}, errors.New("zip contains too many files")
}
if entry.UncompressedSize64 > uint64(guard.MaxSingleFileBytes) {
return packageInfo{}, errors.New("zip entry is too large")
}
total += entry.UncompressedSize64
if total > uint64(guard.MaxDecompressedBytes) {
return packageInfo{}, errors.New("zip decompressed size is too large")
}
if entry.CompressedSize64 == 0 && entry.UncompressedSize64 > 0 {
return packageInfo{}, errors.New("zip entry has invalid compression metadata")
}
if entry.CompressedSize64 > 0 {
ratio := float64(entry.UncompressedSize64) / float64(entry.CompressedSize64)
if ratio > guard.MaxCompressionRatio {
return packageInfo{}, errors.New("zip compression ratio is suspicious")
}
}
files = append(files, cleanName)
if cleanName != "feedback.json" && cleanName != "summary.txt" {
if !guard.AllowUnexpectedZipFiles && !strings.HasPrefix(cleanName, "attachments/") {
return packageInfo{}, errors.New("zip contains unexpected file")
}
continue
}
text, err := readZipText(entry, guard.MaxReadableTextBytes)
if err != nil {
return packageInfo{}, err
}
texts[cleanName] = text
}
request := map[string]any{}
if raw := texts["feedback.json"]; raw != "" {
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
if nested, ok := parsed["request"].(map[string]any); ok {
request = nested
}
}
}
if len(request) == 0 && texts["feedback.json"] == "" {
return packageInfo{}, errors.New("feedback.json is missing")
}
return packageInfo{Request: request, Summary: texts["summary.txt"], Files: files}, nil
}
func safeZipName(name string) (string, error) {
name = strings.ReplaceAll(name, "\\", "/")
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, "\x00") || strings.HasPrefix(name, "/") {
return "", errors.New("unsafe zip entry name")
}
clean := path.Clean(name)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", errors.New("unsafe zip entry path")
}
return clean, nil
}
func readZipText(entry *zip.File, maxBytes int64) (string, error) {
if int64(entry.UncompressedSize64) > maxBytes {
return "", nil
}
reader, err := entry.Open()
if err != nil {
return "", err
}
defer reader.Close()
data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
if err != nil {
return "", err
}
if int64(len(data)) > maxBytes {
return "", nil
}
return string(data), nil
}
func buildRecord(code string, payload submissionPayload, info packageInfo, encryptedPath, packagePath, packageSha256, plainPackageSha256, remoteAddr string) db.FeedbackRecord {
now := db.Now()
title := firstText(textFromMap(info.Request, "title"), payload.Title, "未命名反馈")
typ := firstText(textFromMap(info.Request, "type"), payload.Type, "issue")
severity := firstText(textFromMap(info.Request, "severity"), payload.Severity, "normal")
contact := firstText(textFromMap(info.Request, "contact"), payload.Contact, "")
body := firstText(textFromMap(info.Request, "body"), "", "")
priority := normalizePriority(severity)
return db.FeedbackRecord{
Code: code,
ReceivedAt: now,
Title: shortenPlain(title, 240),
Type: shortenPlain(typ, 80),
Severity: shortenPlain(severity, 80),
Category: normalizeCategory(typ),
Priority: priority,
Contact: shortenPlain(contact, 240),
Body: shortenPlain(body, 5000),
Status: "new",
StatusDetail: "反馈已接收,等待后台处理。",
Note: "",
PublicReply: "",
HandledBy: "",
Assignee: "",
DueAt: "",
ResolvedAt: "",
ArchivedAt: "",
SLALevel: defaultSLA(priority),
SourceChannel: "winui",
RiskScore: defaultRisk(priority),
Resolution: "",
PackagePath: packagePath,
EncryptedPackagePath: encryptedPath,
PackageSha256: packageSha256,
PlainPackageSha256: plainPackageSha256,
RemoteAddr: shortenPlain(remoteAddr, 80),
SummaryText: shortenPlain(info.Summary, 6000),
IncludedFiles: strings.Join(info.Files, ", "),
MailSent: false,
UpdatedAt: now,
LastActivityAt: now,
}
}
func (s *Service) generateCode() string {
for {
random := make([]byte, 3)
if _, err := rand.Read(random); err != nil {
panic(err)
}
code := "FB-" + time.Now().UTC().Format("20060102") + "-" + strings.ToUpper(hex.EncodeToString(random))
existing, _ := s.store.FetchStatus(code)
if existing == nil {
return code
}
}
}
func StatusLabel(status string) string {
switch status {
case "triaged":
return "已归类"
case "investigating":
return "处理中"
case "resolved":
return "已解决"
case "archived":
return "已归档"
default:
return "新反馈"
}
}
func TypeLabel(value string) string {
switch strings.ToLower(value) {
case "suggestion":
return "建议"
case "ui":
return "界面反馈"
case "other":
return "其他"
default:
return "问题"
}
}
func SeverityLabel(value string) string {
switch strings.ToLower(value) {
case "major":
return "影响使用"
case "blocking":
return "阻塞"
default:
return "普通"
}
}
func Fail(c *gin.Context, code string, status int, message string) {
c.JSON(status, gin.H{
"ok": false,
"error": code,
"message": message,
})
}
func firstText(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func textFromMap(values map[string]any, key string) string {
if value, ok := values[key].(string); ok {
return value
}
return ""
}
func shortenPlain(value string, max int) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
if max <= 0 || len([]rune(value)) <= max {
return value
}
runes := []rune(value)
return string(runes[:max])
}
func isHexSHA256(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if len(value) != 64 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func isZipBytes(data []byte) bool {
return bytes.HasPrefix(data, []byte("PK\x03\x04")) ||
bytes.HasPrefix(data, []byte("PK\x05\x06")) ||
bytes.HasPrefix(data, []byte("PK\x07\x08"))
}
func normalizeCategory(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "suggestion", "ui", "other":
return strings.ToLower(strings.TrimSpace(value))
default:
return "issue"
}
}
func normalizePriority(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "major", "blocking":
return strings.ToLower(strings.TrimSpace(value))
default:
return "normal"
}
}
func defaultSLA(priority string) string {
switch normalizePriority(priority) {
case "blocking":
return "urgent"
case "major":
return "elevated"
default:
return "standard"
}
}
func defaultRisk(priority string) int {
switch normalizePriority(priority) {
case "blocking":
return 90
case "major":
return 65
default:
return 30
}
}
func DebugEncryptPackageForTest(plain []byte, keyMaterial string, nonce []byte) ([]byte, error) {
if len(nonce) != 12 {
return nil, fmt.Errorf("nonce must be 12 bytes")
}
if keyMaterial == "" {
keyMaterial = "ymhut-box-feedback-package-v1"
}
key := sha256.Sum256([]byte(keyMaterial))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
sealed := gcm.Seal(nil, nonce, plain, []byte(PackageMagic))
ciphertext := sealed[:len(sealed)-gcm.Overhead()]
tag := sealed[len(sealed)-gcm.Overhead():]
out := []byte(PackageMagic)
out = append(out, nonce...)
out = append(out, tag...)
out = append(out, ciphertext...)
return out, nil
}
@@ -0,0 +1,97 @@
package feedback
import (
"archive/zip"
"bytes"
"testing"
"ymhut-box/server/feedback-mailer/internal/config"
)
func TestSubmissionSignatureIsStable(t *testing.T) {
signature := SignWithKey(
"ymhut-box-feedback-client-v1",
"1760000000",
"abc123",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
`{"ok":true}`,
)
const expected = "9bb27ac870cddf4b9eb02961f2f744bb4cf02b7a08e190ede5d836e5c946ad2e"
if signature != expected {
t.Fatalf("signature mismatch: got %s want %s", signature, expected)
}
}
func TestDecryptPackageAndReadFeedbackPackage(t *testing.T) {
var zipBuffer bytes.Buffer
writer := zip.NewWriter(&zipBuffer)
feedbackEntry, err := writer.Create("feedback.json")
if err != nil {
t.Fatal(err)
}
if _, err := feedbackEntry.Write([]byte(`{"request":{"title":"Crash","type":"issue","severity":"major","contact":"dev@example.com","body":"Steps"}}`)); err != nil {
t.Fatal(err)
}
summaryEntry, err := writer.Create("summary.txt")
if err != nil {
t.Fatal(err)
}
if _, err := summaryEntry.Write([]byte("summary text")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
encrypted, err := DebugEncryptPackageForTest(zipBuffer.Bytes(), "ymhut-box-feedback-package-v1", []byte("123456789012"))
if err != nil {
t.Fatal(err)
}
plain, err := DecryptPackage(encrypted, "ymhut-box-feedback-package-v1")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(plain, zipBuffer.Bytes()) {
t.Fatal("decrypted package did not match original zip")
}
info, err := ReadFeedbackPackage(plain)
if err != nil {
t.Fatal(err)
}
if info.Request["title"] != "Crash" || info.Summary != "summary text" {
t.Fatalf("unexpected package info: %+v", info)
}
if len(info.Files) != 2 {
t.Fatalf("expected two files, got %v", info.Files)
}
}
func TestNormalizeCode(t *testing.T) {
if got := NormalizeCode(" fb-20260604-abc123 "); got != "FB-20260604-ABC123" {
t.Fatalf("unexpected normalized code %q", got)
}
if got := NormalizeCode("FB-20260604-XYZ123"); got != "" {
t.Fatalf("invalid code was accepted: %q", got)
}
}
func TestReadFeedbackPackageRejectsUnsafeZipPath(t *testing.T) {
var zipBuffer bytes.Buffer
writer := zip.NewWriter(&zipBuffer)
entry, err := writer.Create("../evil.txt")
if err != nil {
t.Fatal(err)
}
if _, err := entry.Write([]byte("evil")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if _, err := ReadFeedbackPackageWithGuard(zipBuffer.Bytes(), config.Default(".").UploadGuard); err == nil {
t.Fatal("expected unsafe zip path to be rejected")
}
}
@@ -0,0 +1,322 @@
package mail
import (
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"ymhut-box/server/feedback-mailer/internal/config"
"ymhut-box/server/feedback-mailer/internal/db"
)
type Message struct {
From string
FromName string
To string
Subject string
PlainBody string
HTMLBody string
AttachmentPath string
AttachmentName string
}
func BuildFeedbackMessage(cfg *config.Config, record db.FeedbackRecord, packagePath string) (Message, error) {
channel, err := channel(cfg)
if err != nil {
return Message{}, err
}
subject := "[" + record.Code + "] YMhut Box 反馈:" + truncate(record.Title, 80)
return Message{
From: channel.FromAddress,
FromName: channel.FromName,
To: channel.DeveloperAddress,
Subject: subject,
PlainBody: buildFeedbackPlain(record),
HTMLBody: buildFeedbackHTML(record),
AttachmentPath: packagePath,
AttachmentName: record.Code + ".zip",
}, nil
}
func BuildTestMessage(cfg *config.Config) (Message, error) {
channel, err := channel(cfg)
if err != nil {
return Message{}, err
}
now := time.Now().UTC().Format(time.RFC3339)
return Message{
From: channel.FromAddress,
FromName: channel.FromName,
To: channel.DeveloperAddress,
Subject: "YMhut Box 反馈中心测试通知",
PlainBody: "这是一封来自反馈中心后台的测试通知。\n时间:" + now,
HTMLBody: "<p>这是一封来自反馈中心后台的测试通知。</p><p>时间:" + htmlEscape(now) + "</p>",
}, nil
}
func Send(cfg *config.Config, message Message) error {
channel, err := channel(cfg)
if err != nil {
return err
}
raw, err := BuildMIME(message)
if err != nil {
return err
}
return smtpSend(channel, message.From, message.To, raw)
}
func channel(cfg *config.Config) (config.MailConfig, error) {
channel := cfg.Mail
if channel.FromAddress == "" {
channel.FromAddress = channel.Username
}
if channel.FromName == "" {
channel.FromName = "YMhut Box Feedback"
}
if channel.Port <= 0 {
channel.Port = 465
}
if channel.TimeoutSeconds <= 0 {
channel.TimeoutSeconds = 20
}
channel.Secure = strings.ToLower(channel.Secure)
if channel.Secure == "" {
channel.Secure = "ssl"
}
if channel.Host == "" || channel.FromAddress == "" || channel.DeveloperAddress == "" {
return channel, errors.New("通知配置不完整")
}
return channel, nil
}
func BuildMIME(message Message) (string, error) {
boundary := "ymhut_" + randomish()
altBoundary := "ymhut_alt_" + randomish()
headers := []string{
"Date: " + time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05") + " +0000",
"From: " + mimeAddress(message.From, message.FromName),
"To: " + message.To,
"Subject: " + mime.BEncoding.Encode("UTF-8", message.Subject),
"MIME-Version: 1.0",
`Content-Type: multipart/mixed; boundary="` + boundary + `"`,
}
body := []string{
"--" + boundary,
`Content-Type: multipart/alternative; boundary="` + altBoundary + `"`,
"",
"--" + altBoundary,
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: base64",
"",
wrapBase64([]byte(message.PlainBody)),
"--" + altBoundary,
"Content-Type: text/html; charset=UTF-8",
"Content-Transfer-Encoding: base64",
"",
wrapBase64([]byte(message.HTMLBody)),
"--" + altBoundary + "--",
}
if message.AttachmentPath != "" {
data, err := os.ReadFile(message.AttachmentPath)
if err == nil {
name := message.AttachmentName
if name == "" {
name = filepath.Base(message.AttachmentPath)
}
escaped := strings.ReplaceAll(strings.ReplaceAll(name, `\`, `\\`), `"`, `\"`)
body = append(body,
"--"+boundary,
`Content-Type: application/zip; name="`+escaped+`"`,
"Content-Transfer-Encoding: base64",
`Content-Disposition: attachment; filename="`+escaped+`"`,
"",
wrapBase64(data),
)
}
}
body = append(body, "--"+boundary+"--")
return strings.Join(headers, "\r\n") + "\r\n\r\n" + strings.Join(body, "\r\n"), nil
}
func smtpSend(channel config.MailConfig, from, to, rawMessage string) error {
address := net.JoinHostPort(channel.Host, fmt.Sprintf("%d", channel.Port))
timeout := time.Duration(channel.TimeoutSeconds) * time.Second
var client *smtp.Client
if channel.Secure == "ssl" || channel.Secure == "tls" {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", address, &tls.Config{ServerName: channel.Host})
if err != nil {
return fmt.Errorf("通知连接失败:%w", err)
}
client, err = smtp.NewClient(conn, channel.Host)
if err != nil {
conn.Close()
return err
}
} else {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return fmt.Errorf("通知连接失败:%w", err)
}
client, err = smtp.NewClient(conn, channel.Host)
if err != nil {
conn.Close()
return err
}
}
defer client.Close()
if channel.Secure == "starttls" {
if err := client.StartTLS(&tls.Config{ServerName: channel.Host}); err != nil {
return fmt.Errorf("通知加密握手失败:%w", err)
}
}
if channel.Username != "" || channel.Password != "" {
if err := client.Auth(smtp.PlainAuth("", channel.Username, channel.Password, channel.Host)); err != nil {
return fmt.Errorf("通知认证失败:%w", err)
}
}
if err := client.Mail(extractEmail(from)); err != nil {
return err
}
if err := client.Rcpt(extractEmail(to)); err != nil {
return err
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write([]byte(rawMessage)); err != nil {
writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
func buildFeedbackPlain(record db.FeedbackRecord) string {
return strings.Join([]string{
"YMhut Box 反馈单",
"反馈编号:" + record.Code,
"标题:" + record.Title,
"类型:" + typeLabel(record.Type),
"严重程度:" + severityLabel(record.Severity),
"联系方式:" + record.Contact,
"接收时间:" + record.ReceivedAt,
"包含文件:" + record.IncludedFiles,
"原始包校验:" + record.PlainPackageSha256,
"",
"正文:",
record.Body,
"",
"反馈包摘要:",
record.SummaryText,
}, "\n")
}
func buildFeedbackHTML(record db.FeedbackRecord) string {
rows := [][2]string{
{"反馈编号", record.Code},
{"标题", record.Title},
{"类型", typeLabel(record.Type)},
{"严重程度", severityLabel(record.Severity)},
{"联系方式", record.Contact},
{"接收时间", record.ReceivedAt},
{"包含文件", record.IncludedFiles},
{"原始包校验", record.PlainPackageSha256},
}
html := `<h2>YMhut Box 反馈单</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">`
for _, row := range rows {
html += "<tr><th align=\"left\">" + htmlEscape(row[0]) + "</th><td>" + strings.ReplaceAll(htmlEscape(row[1]), "\n", "<br>") + "</td></tr>"
}
html += "</table>"
html += `<h3>正文</h3><p style="white-space:pre-wrap">` + htmlEscape(record.Body) + "</p>"
html += `<h3>反馈包摘要</h3><pre style="white-space:pre-wrap">` + htmlEscape(record.SummaryText) + "</pre>"
return html
}
func typeLabel(value string) string {
switch strings.ToLower(value) {
case "suggestion":
return "建议"
case "ui":
return "界面反馈"
case "other":
return "其他"
default:
return "问题"
}
}
func severityLabel(value string) string {
switch strings.ToLower(value) {
case "major":
return "影响使用"
case "blocking":
return "阻塞"
default:
return "普通"
}
}
func htmlEscape(value string) string {
value = strings.ReplaceAll(value, "&", "&amp;")
value = strings.ReplaceAll(value, "<", "&lt;")
value = strings.ReplaceAll(value, ">", "&gt;")
value = strings.ReplaceAll(value, `"`, "&quot;")
return strings.ReplaceAll(value, "'", "&#39;")
}
func mimeAddress(address, name string) string {
if name == "" {
return address
}
return mime.BEncoding.Encode("UTF-8", name) + " <" + extractEmail(address) + ">"
}
func extractEmail(value string) string {
re := regexp.MustCompile(`<([^>]+)>`)
if match := re.FindStringSubmatch(value); len(match) == 2 {
return strings.TrimSpace(match[1])
}
return strings.TrimSpace(value)
}
func wrapBase64(data []byte) string {
encoded := base64.StdEncoding.EncodeToString(data)
var builder strings.Builder
for len(encoded) > 76 {
builder.WriteString(encoded[:76])
builder.WriteString("\r\n")
encoded = encoded[76:]
}
builder.WriteString(encoded)
return builder.String()
}
func randomish() string {
return strings.ReplaceAll(fmt.Sprintf("%d", time.Now().UnixNano()), "-", "")
}
func truncate(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
@@ -0,0 +1,115 @@
package web
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
"ymhut-box/server/feedback-mailer/internal/config"
)
type rateLimitSet struct {
mu sync.Mutex
buckets map[string]*visitorBucket
cfg *config.Config
}
type visitorBucket struct {
limiter *rate.Limiter
lastSeen time.Time
}
func newRateLimitSet(cfg *config.Config) *rateLimitSet {
return &rateLimitSet{cfg: cfg, buckets: map[string]*visitorBucket{}}
}
func (s *rateLimitSet) allow(kind, ip string) bool {
if ip == "" {
ip = "unknown"
}
limit, burst := s.policy(kind)
key := kind + ":" + ip
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if len(s.buckets) > 4096 {
for key, bucket := range s.buckets {
if now.Sub(bucket.lastSeen) > 10*time.Minute {
delete(s.buckets, key)
}
}
}
bucket, ok := s.buckets[key]
if !ok {
bucket = &visitorBucket{limiter: rate.NewLimiter(limit, burst)}
s.buckets[key] = bucket
}
bucket.lastSeen = now
return bucket.limiter.Allow()
}
func (s *rateLimitSet) middleware(kind string) gin.HandlerFunc {
return func(c *gin.Context) {
if !s.allow(kind, c.ClientIP()) {
tooManyRequests(c)
return
}
c.Next()
}
}
func (s *rateLimitSet) adminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
kind := "admin_read"
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead && c.Request.Method != http.MethodOptions {
kind = "admin_write"
}
if !s.allow(kind, c.ClientIP()) {
tooManyRequests(c)
return
}
c.Next()
}
}
func (s *rateLimitSet) policy(kind string) (rate.Limit, int) {
perMinute := s.cfg.RateLimit.AdminReadPerMinute
burst := s.cfg.RateLimit.AdminReadBurst
switch kind {
case "submission":
perMinute = s.cfg.RateLimit.SubmissionPerMinute
burst = s.cfg.RateLimit.SubmissionBurst
case "status":
perMinute = s.cfg.RateLimit.StatusPerMinute
burst = s.cfg.RateLimit.StatusBurst
case "captcha":
perMinute = s.cfg.RateLimit.CaptchaPerMinute
burst = s.cfg.RateLimit.CaptchaBurst
case "login":
perMinute = s.cfg.RateLimit.LoginPerMinute
burst = s.cfg.RateLimit.LoginBurst
case "admin_write":
perMinute = s.cfg.RateLimit.AdminWritePerMinute
burst = s.cfg.RateLimit.AdminWriteBurst
}
if perMinute <= 0 {
perMinute = 60
}
if burst <= 0 {
burst = 5
}
return rate.Limit(float64(perMinute) / 60.0), burst
}
func tooManyRequests(c *gin.Context) {
c.JSON(http.StatusTooManyRequests, gin.H{
"ok": false,
"error": "RATE_LIMITED",
"message": "Too many requests, please retry later",
})
c.Abort()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
package webhook
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"ymhut-box/server/feedback-mailer/internal/config"
"ymhut-box/server/feedback-mailer/internal/db"
)
type Dispatcher struct {
cfg *config.Config
store *db.Store
}
type Event struct {
Event string `json:"event"`
Delivery string `json:"delivery"`
OccurredAt string `json:"occurredAt"`
Data any `json:"data"`
}
func NewDispatcher(cfg *config.Config, store *db.Store) *Dispatcher {
return &Dispatcher{cfg: cfg, store: store}
}
func (d *Dispatcher) Dispatch(event string, data any) {
if d == nil || len(d.cfg.Webhooks) == 0 {
return
}
for _, hook := range d.cfg.Webhooks {
if !hook.Enabled || hook.URL == "" || !matchesEvent(hook.Events, event) {
continue
}
hookCopy := hook
go d.Deliver(hookCopy, event, data)
}
}
func (d *Dispatcher) DispatchSync(event string, data any) {
if d == nil || len(d.cfg.Webhooks) == 0 {
return
}
for _, hook := range d.cfg.Webhooks {
if !hook.Enabled || hook.URL == "" || !matchesEvent(hook.Events, event) {
continue
}
d.Deliver(hook, event, data)
}
}
func (d *Dispatcher) DispatchTest(data any) int {
if d == nil || len(d.cfg.Webhooks) == 0 {
return 0
}
count := 0
for _, hook := range d.cfg.Webhooks {
if !hook.Enabled || hook.URL == "" {
continue
}
d.Deliver(hook, "feedback.test", data)
count++
}
return count
}
func (d *Dispatcher) Deliver(hook config.WebhookConfig, event string, data any) {
if d == nil || hook.URL == "" {
return
}
payload, deliveryKey := buildPayload(event, data)
sum := sha256.Sum256(payload)
id, err := d.store.InsertWebhookDelivery(db.WebhookDelivery{
WebhookName: hook.Name,
Event: event,
Status: "pending",
PayloadSHA256: hex.EncodeToString(sum[:]),
CreatedAt: db.Now(),
})
if err != nil {
return
}
maxRetries := hook.MaxRetries
if maxRetries < 0 {
maxRetries = 0
}
attempts := 0
status := "failed"
responseCode := 0
errorMessage := ""
for attempts <= maxRetries {
attempts++
code, err := postJSON(hook, event, deliveryKey, payload)
responseCode = code
if err == nil && code >= 200 && code < 300 {
status = "sent"
errorMessage = ""
break
}
if err != nil {
errorMessage = err.Error()
} else {
errorMessage = "webhook returned HTTP " + http.StatusText(code)
if errorMessage == "webhook returned HTTP " {
errorMessage = "webhook returned HTTP status"
}
}
if attempts <= maxRetries {
time.Sleep(time.Duration(attempts) * 350 * time.Millisecond)
}
}
_ = d.store.FinishWebhookDelivery(id, status, attempts, responseCode, errorMessage)
}
func buildPayload(event string, data any) ([]byte, string) {
now := db.Now()
rawDelivery := sha256.Sum256([]byte(event + "\n" + now + "\n" + db.ToJSON(data)))
delivery := hex.EncodeToString(rawDelivery[:16])
payload := Event{Event: event, Delivery: delivery, OccurredAt: now, Data: data}
encoded, err := json.Marshal(payload)
if err != nil {
return []byte(`{"event":"` + event + `"}`), delivery
}
return encoded, delivery
}
func postJSON(hook config.WebhookConfig, event, delivery string, body []byte) (int, error) {
timeout := hook.TimeoutSeconds
if timeout <= 0 {
timeout = 5
}
request, err := http.NewRequest(http.MethodPost, hook.URL, bytes.NewReader(body))
if err != nil {
return 0, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "YMhut-Feedback-Webhook/1.0")
request.Header.Set("X-YMhut-Event", event)
request.Header.Set("X-YMhut-Delivery", delivery)
request.Header.Set("X-YMhut-Signature", sign(hook.Secret, body))
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return response.StatusCode, nil
}
func sign(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
func matchesEvent(events []string, event string) bool {
if len(events) == 0 {
return true
}
for _, candidate := range events {
candidate = strings.TrimSpace(candidate)
if candidate == "*" || candidate == event {
return true
}
if strings.HasSuffix(candidate, ".*") && strings.HasPrefix(event, strings.TrimSuffix(candidate, "*")) {
return true
}
}
return false
}