package web import ( "encoding/base64" "encoding/csv" "fmt" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "ymhut-box/server/feedback-mailer/internal/auth" "ymhut-box/server/feedback-mailer/internal/config" "ymhut-box/server/feedback-mailer/internal/db" "ymhut-box/server/feedback-mailer/internal/feedback" feedbackmail "ymhut-box/server/feedback-mailer/internal/mail" "ymhut-box/server/feedback-mailer/internal/webhook" ) func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service) *gin.Engine { gin.SetMode(gin.ReleaseMode) router := gin.New() router.Use(gin.Logger(), gin.Recovery()) limiter := newRateLimitSet(cfg) hooks := webhook.NewDispatcher(cfg, store) feedbackService := feedback.NewService(cfg, store, hooks) distDir := filepath.Join(cfg.BaseDir, "web", "dist") router.GET("/", func(c *gin.Context) { if c.Query("api") == "status" { if !limiter.allow("status", c.ClientIP()) { tooManyRequests(c) return } feedbackService.HandleStatus(c) return } serveAppIndex(c, distDir) }) router.POST("/", limiter.middleware("submission"), feedbackService.HandleSubmission) api := router.Group("/api") api.GET("/auth/captcha", limiter.middleware("captcha"), func(c *gin.Context) { captcha, err := authService.NewCaptcha() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "CAPTCHA_FAILED", "message": "Unable to create captcha"}) return } c.JSON(http.StatusOK, gin.H{ "ok": true, "captchaId": captcha.ID, "image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(captcha.ImagePNG), }) }) api.POST("/auth/login", limiter.middleware("login"), func(c *gin.Context) { var body struct { Password string `json:"password"` CaptchaID string `json:"captchaId"` CaptchaAnswer string `json:"captcha"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid login payload"}) return } sessionID, csrf, ok := authService.Login(body.Password, body.CaptchaID, body.CaptchaAnswer) if !ok { _ = audit(c, store, "admin", "login.failed", "", "Login failed") c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "LOGIN_FAILED", "message": "Password or captcha is incorrect"}) return } authService.SetSessionCookie(c, sessionID) _ = audit(c, store, "admin", "login.success", "", "Login succeeded") c.JSON(http.StatusOK, gin.H{"ok": true, "csrfToken": csrf}) }) api.POST("/auth/logout", authService.RequireAuth, authService.RequireCSRF, func(c *gin.Context) { _ = audit(c, store, "admin", "logout", "", "Logout") authService.Logout(c) c.JSON(http.StatusOK, gin.H{"ok": true}) }) admin := api.Group("/admin", limiter.adminMiddleware(), authService.RequireAuth, authService.RequireCSRF) admin.GET("/overview", func(c *gin.Context) { overview, err := store.Overview() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load overview"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "overview": overview}) }) admin.GET("/config", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "config": buildConfigHealth(cfg, store)}) }) admin.PATCH("/config", func(c *gin.Context) { handleConfigUpdate(c, cfg, store) }) admin.POST("/config/test/database", func(c *gin.Context) { var body config.DatabaseConfig if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid database config"}) return } if body.Password == "" { body.Password = cfg.Database.Password } testCfg := *cfg testCfg.Database = body if err := db.TestDatabase(testCfg.Database, cfg.BaseDir); err != nil { _ = audit(c, store, "admin", "config.database_test.failed", "", err.Error()) c.JSON(http.StatusBadGateway, gin.H{"ok": false, "error": "DATABASE_TEST_FAILED", "message": err.Error()}) return } _ = audit(c, store, "admin", "config.database_test.ok", "", "Database connection test succeeded") c.JSON(http.StatusOK, gin.H{"ok": true}) }) admin.POST("/config/test/webhook", func(c *gin.Context) { var hook config.WebhookConfig if err := c.ShouldBindJSON(&hook); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid webhook config"}) return } if hook.Secret == "" { for _, existing := range cfg.Webhooks { if existing.Name == hook.Name || existing.URL == hook.URL { hook.Secret = existing.Secret break } } } temp := *cfg temp.Webhooks = []config.WebhookConfig{hook} count := webhook.NewDispatcher(&temp, store).DispatchTest(gin.H{"test": true, "sentAt": db.Now()}) _ = audit(c, store, "admin", "config.webhook_test", hook.Name, fmt.Sprintf("Triggered %d webhook tests", count)) c.JSON(http.StatusOK, gin.H{"ok": true, "sent": count}) }) admin.GET("/database/status", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "database": store.Status()}) }) admin.POST("/database/import", func(c *gin.Context) { result, err := store.ImportSQLiteToRemote() if err != nil { _ = audit(c, store, "admin", "database.import.failed", "", err.Error()) c.JSON(http.StatusBadGateway, gin.H{"ok": false, "error": "DATABASE_IMPORT_FAILED", "message": err.Error()}) return } _ = audit(c, store, "admin", "database.import", "", "SQLite imported to remote database") c.JSON(http.StatusOK, gin.H{"ok": true, "result": result}) }) admin.POST("/database/sync", func(c *gin.Context) { result, err := store.SyncNow() if err != nil { _ = audit(c, store, "admin", "database.sync.failed", "", err.Error()) c.JSON(http.StatusBadGateway, gin.H{"ok": false, "error": "DATABASE_SYNC_FAILED", "message": err.Error()}) return } _ = audit(c, store, "admin", "database.sync", "", "Database sync completed") c.JSON(http.StatusOK, gin.H{"ok": true, "result": result}) }) admin.GET("/feedbacks", func(c *gin.Context) { page := queryInt(c, "page", 1) perPage := queryInt(c, "perPage", 20) result, err := store.ListFeedbacks(page, perPage, feedbackFiltersFromQuery(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load feedbacks"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "page": result}) }) admin.GET("/feedbacks/export", func(c *gin.Context) { records, err := store.ExportFeedbacks(feedbackFiltersFromQuery(c), 10000) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to export feedbacks"}) return } _ = audit(c, store, "admin", "feedback.export", "", fmt.Sprintf("Exported %d feedbacks", len(records))) writeFeedbackCSV(c, records) }) admin.GET("/feedbacks/summary", func(c *gin.Context) { summary, err := store.FeedbackSummary() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load feedback summary"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "summary": summary}) }) admin.GET("/feedbacks/:code", func(c *gin.Context) { code := feedback.NormalizeCode(c.Param("code")) if code == "" { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid feedback code"}) return } detail, err := store.GetFeedbackDetail(code) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load feedback detail"}) return } if detail == nil { c.JSON(http.StatusNotFound, gin.H{"ok": false, "error": "NOT_FOUND", "message": "Feedback not found"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "feedback": detail}) }) admin.PATCH("/feedbacks/bulk", func(c *gin.Context) { handleBulkFeedbackUpdate(c, store, hooks) }) admin.PATCH("/feedbacks/:code", func(c *gin.Context) { handleFeedbackUpdate(c, store, hooks) }) admin.POST("/feedbacks/:code/comments", func(c *gin.Context) { handleFeedbackComment(c, store, hooks) }) admin.GET("/mails", func(c *gin.Context) { page := queryInt(c, "page", 1) perPage := queryInt(c, "perPage", 20) status := strings.TrimSpace(c.Query("status")) result, err := store.ListMails(page, perPage, status) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load mail records"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "page": result}) }) admin.POST("/mails/test", func(c *gin.Context) { message, err := feedbackmail.BuildTestMessage(cfg) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"ok": false, "error": "MAIL_FAILED", "message": err.Error()}) return } id, err := store.InsertMail(db.MailRecord{ FeedbackCode: "", Kind: "test", Status: "pending", ToAddress: message.To, Subject: message.Subject, PlainBody: message.PlainBody, HTMLBody: message.HTMLBody, AttachmentPath: "", AttachmentName: "", CreatedAt: db.Now(), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to retain mail record"}) return } if err := feedbackmail.Send(cfg, message); err != nil { _ = store.UpdateMailState(id, "failed", limitText(err.Error(), 1000)) hooks.Dispatch("mail.failed", gin.H{"mailId": id, "kind": "test", "error": err.Error()}) _ = audit(c, store, "admin", "mail.test.failed", "", err.Error()) c.JSON(http.StatusBadGateway, gin.H{"ok": false, "error": "MAIL_FAILED", "message": err.Error()}) return } _ = store.UpdateMailState(id, "sent", "") _ = audit(c, store, "admin", "mail.test.sent", "", "Test mail sent") c.JSON(http.StatusOK, gin.H{"ok": true}) }) admin.GET("/audit-logs", func(c *gin.Context) { page, err := store.ListAuditLogs(queryInt(c, "page", 1), queryInt(c, "perPage", 20), c.Query("actor"), c.Query("type")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load audit logs"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "page": page}) }) admin.GET("/webhooks/deliveries", func(c *gin.Context) { page, err := store.ListWebhookDeliveries(queryInt(c, "page", 1), queryInt(c, "perPage", 20), c.Query("status")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load webhook deliveries"}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "page": page, "webhooks": webhookSummaries(cfg)}) }) admin.POST("/webhooks/test", func(c *gin.Context) { count := hooks.DispatchTest(gin.H{"test": true, "sentAt": db.Now()}) _ = audit(c, store, "admin", "webhook.test", "", fmt.Sprintf("Triggered %d webhook tests", count)) c.JSON(http.StatusOK, gin.H{"ok": true, "sent": count}) }) admin.POST("/backups/database", func(c *gin.Context) { name := "feedback-" + time.Now().UTC().Format("20060102-150405") + ".sqlite" path := filepath.Join(cfg.Backup.Dir, name) if !isInside(cfg.Backup.Dir, path) { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Invalid backup path"}) return } if err := store.BackupDatabase(path); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to create backup"}) return } _ = audit(c, store, "admin", "backup.created", name, "Database backup created") c.JSON(http.StatusOK, gin.H{"ok": true, "backup": backupInfo(path)}) }) admin.GET("/backups", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "backups": listBackups(cfg.Backup.Dir)}) }) admin.GET("/backups/:name", func(c *gin.Context) { name := c.Param("name") if !safeBackupName(name) { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid backup name"}) return } path := filepath.Join(cfg.Backup.Dir, name) if !isInside(cfg.Backup.Dir, path) || !fileExists(path) { c.JSON(http.StatusNotFound, gin.H{"ok": false, "error": "NOT_FOUND", "message": "Backup not found"}) return } _ = audit(c, store, "admin", "backup.download", name, "Database backup downloaded") c.FileAttachment(path, name) }) mountAdmin(router, distDir) return router } func mountAdmin(router *gin.Engine, distDir string) { router.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/admin/") }) router.GET("/admin/*path", func(c *gin.Context) { requested := strings.TrimPrefix(c.Param("path"), "/") if requested != "" { path := filepath.Join(distDir, filepath.FromSlash(requested)) if isInside(distDir, path) { if info, err := os.Stat(path); err == nil && !info.IsDir() { c.File(path) return } } } index := filepath.Join(distDir, "index.html") if info, err := os.Stat(index); err == nil && !info.IsDir() { c.File(index) return } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, "YMhut Box Feedback

YMhut Box Feedback

Admin frontend is not built. Run npm --prefix admin-web run build.

") }) } func serveAppIndex(c *gin.Context, distDir string) { index := filepath.Join(distDir, "index.html") if info, err := os.Stat(index); err == nil && !info.IsDir() { c.File(index) return } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, "YMhut Box Feedback

YMhut Box Feedback

Frontend is not built. Run npm --prefix admin-web run build.

") } func handleFeedbackUpdate(c *gin.Context, store *db.Store, hooks *webhook.Dispatcher) { code := feedback.NormalizeCode(c.Param("code")) if code == "" { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid feedback code"}) return } current, err := store.GetFeedback(code) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to load feedback"}) return } if current == nil { c.JSON(http.StatusNotFound, gin.H{"ok": false, "error": "NOT_FOUND", "message": "Feedback not found"}) return } var body struct { Status string `json:"status"` Category string `json:"category"` Priority string `json:"priority"` StatusDetail string `json:"statusDetail"` HandledBy string `json:"handledBy"` Assignee string `json:"assignee"` DueAt string `json:"dueAt"` SLALevel string `json:"slaLevel"` Resolution string `json:"resolution"` Note string `json:"note"` PublicReply string `json:"publicReply"` Tags []string `json:"tags"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid feedback payload"}) return } if !allowedStatus(body.Status) || !allowedCategory(body.Category) || !allowedPriority(body.Priority) || !allowedSLA(body.SLALevel) { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid workflow values"}) return } if body.DueAt != "" && !validOptionalTime(body.DueAt) { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid dueAt"}) return } update := db.FeedbackUpdate{ Status: body.Status, Category: body.Category, Priority: body.Priority, StatusDetail: limitText(body.StatusDetail, 1000), HandledBy: limitText(body.HandledBy, 120), Assignee: limitText(body.Assignee, 120), DueAt: limitText(body.DueAt, 80), SLALevel: body.SLALevel, Resolution: limitText(body.Resolution, 2000), Note: limitText(body.Note, 3000), PublicReply: limitText(body.PublicReply, 3000), Actor: firstNonEmpty(body.HandledBy, body.Assignee, "admin"), Tags: body.Tags, } if err := store.UpdateFeedback(code, update); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to update feedback"}) return } detail, _ := store.GetFeedbackDetail(code) _ = audit(c, store, update.Actor, "feedback.updated", code, "Feedback updated") hooks.Dispatch("feedback.updated", detail) if current.Status != body.Status { hooks.Dispatch("feedback.status_changed", gin.H{"code": code, "from": current.Status, "to": body.Status, "feedback": detail}) } c.JSON(http.StatusOK, gin.H{"ok": true, "feedback": detail}) } func handleBulkFeedbackUpdate(c *gin.Context, store *db.Store, hooks *webhook.Dispatcher) { var body struct { Codes []string `json:"codes"` Status string `json:"status"` Category string `json:"category"` Priority string `json:"priority"` StatusDetail string `json:"statusDetail"` HandledBy string `json:"handledBy"` Assignee string `json:"assignee"` DueAt string `json:"dueAt"` SLALevel string `json:"slaLevel"` Resolution string `json:"resolution"` Note string `json:"note"` PublicReply string `json:"publicReply"` Tags []string `json:"tags"` } if err := c.ShouldBindJSON(&body); err != nil || len(body.Codes) == 0 { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid bulk payload"}) return } if body.Status != "" && !allowedStatus(body.Status) { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid feedback status"}) return } if !allowedCategory(body.Category) || !allowedPriority(body.Priority) || !allowedSLA(body.SLALevel) { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid workflow values"}) return } if body.DueAt != "" && !validOptionalTime(body.DueAt) { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid dueAt"}) return } codes := make([]string, 0, len(body.Codes)) for _, code := range body.Codes { if normalized := feedback.NormalizeCode(code); normalized != "" { codes = append(codes, normalized) } } if len(codes) == 0 { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "No valid feedback codes"}) return } actor := firstNonEmpty(body.HandledBy, body.Assignee, "admin") update := db.FeedbackUpdate{ Status: body.Status, Category: body.Category, Priority: body.Priority, StatusDetail: limitText(body.StatusDetail, 1000), HandledBy: limitText(body.HandledBy, 120), Assignee: limitText(body.Assignee, 120), DueAt: limitText(body.DueAt, 80), SLALevel: body.SLALevel, Resolution: limitText(body.Resolution, 2000), Note: limitText(body.Note, 3000), PublicReply: limitText(body.PublicReply, 3000), Actor: actor, Tags: body.Tags, } if err := store.BulkUpdateFeedback(codes, update); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "SERVER_CONFIG", "message": "Unable to update feedbacks"}) return } _ = audit(c, store, actor, "feedback.bulk_updated", strings.Join(codes, ","), fmt.Sprintf("Bulk updated %d feedbacks", len(codes))) for _, code := range codes { detail, _ := store.GetFeedbackDetail(code) hooks.Dispatch("feedback.updated", detail) } c.JSON(http.StatusOK, gin.H{"ok": true, "updated": len(codes)}) } func handleFeedbackComment(c *gin.Context, store *db.Store, hooks *webhook.Dispatcher) { code := feedback.NormalizeCode(c.Param("code")) if code == "" { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid feedback code"}) return } var body struct { Author string `json:"author"` Body string `json:"body"` Internal bool `json:"internal"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid comment payload"}) return } comment, err := store.InsertComment(db.FeedbackComment{ FeedbackCode: code, Author: limitText(firstNonEmpty(body.Author, "admin"), 120), Body: limitText(body.Body, 3000), Internal: body.Internal, }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Unable to add comment"}) return } _ = audit(c, store, comment.Author, "feedback.comment_created", code, "Comment created") hooks.Dispatch("feedback.comment_created", comment) c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment}) } func feedbackFiltersFromQuery(c *gin.Context) db.FeedbackFilters { return db.FeedbackFilters{ Status: strings.TrimSpace(c.Query("status")), Category: strings.TrimSpace(c.Query("category")), Priority: strings.TrimSpace(c.Query("priority")), Mail: strings.TrimSpace(c.Query("mail")), Query: strings.TrimSpace(c.Query("q")), Assignee: strings.TrimSpace(c.Query("assignee")), Tag: strings.TrimSpace(c.Query("tag")), SLA: strings.TrimSpace(c.Query("sla")), Overdue: strings.TrimSpace(c.Query("overdue")), Sort: strings.TrimSpace(c.Query("sort")), } } func writeFeedbackCSV(c *gin.Context, records []db.FeedbackRecord) { c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", `attachment; filename="feedbacks.csv"`) writer := csv.NewWriter(c.Writer) _ = writer.Write([]string{"code", "received_at", "title", "status", "category", "priority", "sla", "assignee", "due_at", "contact", "mail_sent", "tags", "status_detail", "public_reply", "resolution"}) for _, item := range records { _ = writer.Write([]string{ item.Code, item.ReceivedAt, item.Title, item.Status, item.Category, item.Priority, item.SLALevel, item.Assignee, item.DueAt, item.Contact, strconv.FormatBool(item.MailSent), strings.Join(item.Tags, "|"), item.StatusDetail, item.PublicReply, item.Resolution, }) } writer.Flush() } func handleConfigUpdate(c *gin.Context, cfg *config.Config, store *db.Store) { var body struct { Listen *string `json:"listen"` Database *config.DatabaseConfig `json:"database"` Mail *config.MailConfig `json:"mail"` Webhooks *[]config.WebhookConfig `json:"webhooks"` Backup *config.BackupConfig `json:"backup"` RateLimit *config.RateLimitConfig `json:"rateLimit"` UploadGuard *config.UploadGuardConfig `json:"uploadGuard"` Security *struct { AdminPasswordHash string `json:"adminPasswordHash"` AdminPassword string `json:"adminPassword"` ClientSignatureKey string `json:"clientSignatureKey"` PackageEncryptionKey string `json:"packageEncryptionKey"` } `json:"security"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "INVALID_PAYLOAD", "message": "Invalid config payload"}) return } next := *cfg if body.Listen != nil { next.Listen = strings.TrimSpace(*body.Listen) } if body.Database != nil { database := *body.Database if strings.TrimSpace(database.Password) == "" { database.Password = cfg.Database.Password } next.Database = database } if body.Mail != nil { mail := *body.Mail if strings.TrimSpace(mail.Password) == "" { mail.Password = cfg.Mail.Password } next.Mail = mail } if body.Webhooks != nil { next.Webhooks = mergeWebhookSecrets(*body.Webhooks, cfg.Webhooks) } if body.Backup != nil { next.Backup = *body.Backup } if body.RateLimit != nil { next.RateLimit = *body.RateLimit } if body.UploadGuard != nil { next.UploadGuard = *body.UploadGuard } if body.Security != nil { if strings.TrimSpace(body.Security.AdminPasswordHash) != "" { next.AdminPasswordHash = body.Security.AdminPasswordHash } if strings.TrimSpace(body.Security.AdminPassword) != "" { next.AdminPassword = body.Security.AdminPassword } if strings.TrimSpace(body.Security.ClientSignatureKey) != "" { next.ClientSignatureKey = body.Security.ClientSignatureKey } if strings.TrimSpace(body.Security.PackageEncryptionKey) != "" { next.PackageEncryptionKey = body.Security.PackageEncryptionKey } } if body.Database != nil { testDatabase := next.Database if err := db.TestDatabase(testDatabase, next.BaseDir); err != nil { _ = audit(c, store, "admin", "config.save.database_failed", "", err.Error()) c.JSON(http.StatusBadGateway, gin.H{"ok": false, "error": "DATABASE_TEST_FAILED", "message": err.Error()}) return } } backupDir := resolveServicePath(&next, firstNonEmpty(next.Backup.Dir, cfg.Backup.Dir)) backupName := "feedback-before-config-" + time.Now().UTC().Format("20060102-150405") + ".sqlite" _ = store.BackupDatabase(filepath.Join(backupDir, backupName)) oldListen := cfg.Listen if err := config.Save(&next); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "CONFIG_SAVE_FAILED", "message": err.Error()}) return } *cfg = next if body.Database != nil { if err := store.ApplyDatabaseConfig(cfg.Database); err != nil { _ = audit(c, store, "admin", "config.save.database_degraded", "", err.Error()) } } _ = audit(c, store, "admin", "config.save", "", "Config saved") c.JSON(http.StatusOK, gin.H{"ok": true, "restartRequired": oldListen != cfg.Listen, "config": buildConfigHealth(cfg, store)}) } func mergeWebhookSecrets(next, existing []config.WebhookConfig) []config.WebhookConfig { for index := range next { if strings.TrimSpace(next[index].Secret) != "" { continue } for _, current := range existing { if (next[index].Name != "" && current.Name == next[index].Name) || (next[index].URL != "" && current.URL == next[index].URL) { next[index].Secret = current.Secret break } } } return next } func buildConfigHealth(cfg *config.Config, store *db.Store) gin.H { distDir := filepath.Join(cfg.BaseDir, "web", "dist") indexPath := filepath.Join(distDir, "index.html") assetsPath := filepath.Join(distDir, "assets") serviceBase := displayPath(cfg, cfg.BaseDir) storagePath := displayPath(cfg, cfg.StorageDir) databasePath := displayPath(cfg, cfg.DatabasePath) sqlitePath := displayPath(cfg, cfg.Database.SQLitePath) backupDir := displayPath(cfg, cfg.Backup.Dir) distPath := displayPath(cfg, distDir) storageExists := dirExists(cfg.StorageDir) databaseExists := fileExists(cfg.DatabasePath) indexExists := fileExists(indexPath) assetsExists := dirExists(assetsPath) adminPasswordConfigured := strings.TrimSpace(cfg.AdminPasswordHash) != "" || (strings.TrimSpace(cfg.AdminPassword) != "" && cfg.AdminPassword != "CHANGE_ME_ADMIN_PASSWORD") mailConfigured := cfg.Mail.Host != "" && cfg.Mail.FromAddress != "" && cfg.Mail.DeveloperAddress != "" clientKeyConfigured := strings.TrimSpace(cfg.ClientSignatureKey) != "" packageKeyConfigured := strings.TrimSpace(cfg.PackageEncryptionKey) != "" backups := listBackups(cfg.Backup.Dir) lastBackup := "" if len(backups) > 0 { lastBackup, _ = backups[0]["name"].(string) } return gin.H{ "generatedAt": db.Now(), "service": gin.H{ "listen": cfg.Listen, "baseDir": serviceBase, "timestampWindowSeconds": cfg.TimestampWindowSeconds, "maxRequestBytes": cfg.MaxRequestBytes, "maxPackageBytes": cfg.MaxPackageBytes, }, "security": gin.H{ "adminPasswordConfigured": adminPasswordConfigured, "adminPasswordHashConfigured": strings.TrimSpace(cfg.AdminPasswordHash) != "", "clientSignatureKeyConfigured": clientKeyConfigured, "packageEncryptionKeyConfigured": packageKeyConfigured, "rateLimit": cfg.RateLimit, }, "uploadGuard": cfg.UploadGuard, "storage": gin.H{ "path": storagePath, "bytes": dirSize(cfg.StorageDir), "exists": storageExists, }, "database": gin.H{ "provider": cfg.Database.Provider, "path": sqlitePath, "bytes": fileSize(cfg.DatabasePath), "exists": databaseExists, "walMode": store.WALMode(), "backupDir": backupDir, "lastBackup": lastBackup, "runtime": store.Status(), }, "mail": gin.H{ "configured": mailConfigured, "host": cfg.Mail.Host, "port": cfg.Mail.Port, "secure": cfg.Mail.Secure, "fromAddress": cfg.Mail.FromAddress, "developerAddress": cfg.Mail.DeveloperAddress, "usernameConfigured": strings.TrimSpace(cfg.Mail.Username) != "", "passwordConfigured": strings.TrimSpace(cfg.Mail.Password) != "" && cfg.Mail.Password != "CHANGE_ME_MAIL_PASSWORD", "timeoutSeconds": cfg.Mail.TimeoutSeconds, }, "webhooks": webhookSummaries(cfg), "frontend": gin.H{ "distPath": distPath, "indexExists": indexExists, "assetsExists": assetsExists, }, "editable": editableConfig(cfg), "checks": []gin.H{ {"key": "storage", "label": "存储目录", "status": healthStatus(storageExists), "detail": storagePath}, {"key": "database", "label": "SQLite 数据库", "status": healthStatus(databaseExists), "detail": databasePath}, {"key": "wal", "label": "SQLite WAL", "status": healthStatus(strings.EqualFold(store.WALMode(), "wal")), "detail": store.WALMode()}, {"key": "frontend", "label": "前端构建", "status": healthStatus(indexExists && assetsExists), "detail": distPath}, {"key": "mail", "label": "SMTP 通知", "status": healthStatus(mailConfigured), "detail": cfg.Mail.Host}, {"key": "admin", "label": "后台密码", "status": healthStatus(adminPasswordConfigured), "detail": passwordDetail(adminPasswordConfigured, cfg.AdminPasswordHash != "")}, {"key": "signature", "label": "客户端签名 key", "status": healthStatus(clientKeyConfigured), "detail": configuredDetail(clientKeyConfigured)}, {"key": "package", "label": "反馈包加密 key", "status": healthStatus(packageKeyConfigured), "detail": configuredDetail(packageKeyConfigured)}, }, } } func editableConfig(cfg *config.Config) gin.H { webhooks := []gin.H{} for _, hook := range cfg.Webhooks { webhooks = append(webhooks, gin.H{ "name": hook.Name, "url": hook.URL, "secret": "", "secretConfigured": strings.TrimSpace(hook.Secret) != "", "enabled": hook.Enabled, "events": hook.Events, "timeoutSeconds": hook.TimeoutSeconds, "maxRetries": hook.MaxRetries, }) } return gin.H{ "listen": cfg.Listen, "database": gin.H{ "provider": cfg.Database.Provider, "sqlitePath": displayPath(cfg, cfg.Database.SQLitePath), "host": cfg.Database.Host, "port": cfg.Database.Port, "name": cfg.Database.Name, "user": cfg.Database.User, "password": "", "passwordConfigured": strings.TrimSpace(cfg.Database.Password) != "", "dsn": maskDSN(cfg.Database.DSN), "dsnConfigured": strings.TrimSpace(cfg.Database.DSN) != "", "sslMode": cfg.Database.SSLMode, "maxOpenConns": cfg.Database.MaxOpenConns, "maxIdleConns": cfg.Database.MaxIdleConns, "connMaxLifetimeSeconds": cfg.Database.ConnMaxLifetimeSeconds, "failoverEnabled": cfg.Database.FailoverEnabled, "healthIntervalSeconds": cfg.Database.HealthIntervalSeconds, "sync": cfg.Database.Sync, }, "mail": gin.H{ "host": cfg.Mail.Host, "port": cfg.Mail.Port, "secure": cfg.Mail.Secure, "username": cfg.Mail.Username, "password": "", "passwordConfigured": strings.TrimSpace(cfg.Mail.Password) != "" && cfg.Mail.Password != "CHANGE_ME_MAIL_PASSWORD", "fromAddress": cfg.Mail.FromAddress, "fromName": cfg.Mail.FromName, "developerAddress": cfg.Mail.DeveloperAddress, "timeoutSeconds": cfg.Mail.TimeoutSeconds, }, "backup": gin.H{"dir": displayPath(cfg, cfg.Backup.Dir)}, "rateLimit": cfg.RateLimit, "uploadGuard": cfg.UploadGuard, "webhooks": webhooks, "security": gin.H{ "adminPasswordHashConfigured": strings.TrimSpace(cfg.AdminPasswordHash) != "", "adminPasswordConfigured": strings.TrimSpace(cfg.AdminPassword) != "" && cfg.AdminPassword != "CHANGE_ME_ADMIN_PASSWORD", "clientSignatureKeyConfigured": strings.TrimSpace(cfg.ClientSignatureKey) != "", "packageEncryptionKeyConfigured": strings.TrimSpace(cfg.PackageEncryptionKey) != "", }, } } func displayPath(cfg *config.Config, value string) string { value = strings.TrimSpace(value) if value == "" { return value } if cfg == nil || strings.TrimSpace(cfg.BaseDir) == "" { return filepath.ToSlash(value) } absBase, err := filepath.Abs(cfg.BaseDir) if err != nil { return filepath.ToSlash(value) } absValue, err := filepath.Abs(value) if err != nil { return filepath.ToSlash(value) } rel, err := filepath.Rel(absBase, absValue) if err != nil { return filepath.ToSlash(value) } if rel == "." { return "." } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { return filepath.ToSlash(value) } return filepath.ToSlash(rel) } func resolveServicePath(cfg *config.Config, value string) string { value = strings.TrimSpace(value) if value == "" { return value } if filepath.IsAbs(value) { return filepath.Clean(value) } if cfg == nil || strings.TrimSpace(cfg.BaseDir) == "" { return filepath.Clean(value) } return filepath.Clean(filepath.Join(cfg.BaseDir, value)) } func maskDSN(value string) string { if strings.TrimSpace(value) == "" { return "" } parsed, err := url.Parse(value) if err != nil || parsed.User == nil { return "" } if _, ok := parsed.User.Password(); ok { parsed.User = url.UserPassword(parsed.User.Username(), "***") } return parsed.String() } func webhookSummaries(cfg *config.Config) []gin.H { items := []gin.H{} for _, hook := range cfg.Webhooks { host := "" if parsed, err := url.Parse(hook.URL); err == nil { host = parsed.Host } items = append(items, gin.H{ "name": hook.Name, "host": host, "enabled": hook.Enabled, "events": hook.Events, "secretConfigured": strings.TrimSpace(hook.Secret) != "", "timeoutSeconds": hook.TimeoutSeconds, "maxRetries": hook.MaxRetries, }) } return items } func healthStatus(ok bool) string { if ok { return "ok" } return "missing" } func configuredDetail(ok bool) string { if ok { return "已配置" } return "未配置" } func passwordDetail(ok, hashed bool) string { if !ok { return "未配置" } if hashed { return "已使用哈希密码" } return "已配置明文密码" } func listBackups(dir string) []gin.H { entries, err := os.ReadDir(dir) if err != nil { return []gin.H{} } items := []gin.H{} for _, entry := range entries { if entry.IsDir() || !safeBackupName(entry.Name()) { continue } path := filepath.Join(dir, entry.Name()) items = append(items, backupInfo(path)) } sort.Slice(items, func(i, j int) bool { left, _ := items[i]["createdAt"].(string) right, _ := items[j]["createdAt"].(string) return left > right }) return items } func backupInfo(path string) gin.H { info, err := os.Stat(path) createdAt := "" size := int64(0) if err == nil { createdAt = info.ModTime().UTC().Format(time.RFC3339) size = info.Size() } return gin.H{"name": filepath.Base(path), "bytes": size, "createdAt": createdAt} } func safeBackupName(name string) bool { return regexp.MustCompile(`^feedback-[0-9]{8}-[0-9]{6}\.sqlite$`).MatchString(name) } func audit(c *gin.Context, store *db.Store, actor, typ, target, message string) error { return store.InsertAudit(db.AuditLog{ Actor: firstNonEmpty(actor, "admin"), Type: typ, Target: target, Message: message, IP: c.ClientIP(), UserAgent: c.Request.UserAgent(), }) } func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() } func dirExists(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() } func fileSize(path string) int64 { info, err := os.Stat(path) if err != nil || info.IsDir() { return 0 } return info.Size() } func dirSize(path string) int64 { var total int64 _ = filepath.WalkDir(path, func(_ string, entry os.DirEntry, err error) error { if err != nil || entry.IsDir() { return nil } if info, err := entry.Info(); err == nil { total += info.Size() } return nil }) return total } func queryInt(c *gin.Context, key string, fallback int) int { value, err := strconv.Atoi(c.Query(key)) if err != nil { return fallback } return value } func allowedStatus(value string) bool { switch value { case "", "new", "triaged", "investigating", "resolved", "archived": return true default: return false } } func allowedCategory(value string) bool { switch value { case "", "issue", "suggestion", "ui", "other": return true default: return false } } func allowedPriority(value string) bool { switch value { case "", "normal", "major", "blocking": return true default: return false } } func allowedSLA(value string) bool { switch value { case "", "standard", "elevated", "urgent": return true default: return false } } func validOptionalTime(value string) bool { if value == "" { return true } _, err := time.Parse(time.RFC3339, value) return err == nil } func limitText(value string, max int) string { value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", "")) runes := []rune(value) if len(runes) <= max { return value } return string(runes[:max]) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func isInside(root, path string) bool { rootAbs, err := filepath.Abs(root) if err != nil { return false } pathAbs, err := filepath.Abs(path) if err != nil { return false } rel, err := filepath.Rel(rootAbs, pathAbs) if err != nil { return false } return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)) }