1091 lines
38 KiB
Go
1091 lines
38 KiB
Go
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, "<!doctype html><meta charset=\"utf-8\"><title>YMhut Box Feedback</title><main style=\"font-family:system-ui;padding:32px\"><h1>YMhut Box Feedback</h1><p>Admin frontend is not built. Run npm --prefix admin-web run build.</p></main>")
|
|
})
|
|
}
|
|
|
|
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, "<!doctype html><meta charset=\"utf-8\"><title>YMhut Box Feedback</title><main style=\"font-family:system-ui;padding:32px\"><h1>YMhut Box Feedback</h1><p>Frontend is not built. Run npm --prefix admin-web run build.</p></main>")
|
|
}
|
|
|
|
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))
|
|
}
|