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 FeedbackYMhut 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 FeedbackYMhut 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))
}