diff --git a/admin_help_routes.go b/admin_help_routes.go new file mode 100644 index 0000000..a70a109 --- /dev/null +++ b/admin_help_routes.go @@ -0,0 +1,101 @@ +package main + +import ( + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type helpContentRequest struct { + Markdown string `json:"markdown"` +} + +func registerHelpRoutes(r gin.IRouter, store *store) { + r.GET("/help", func(c *gin.Context) { + item, err := latestHelpContentDTO(store) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"item": item}) + }) +} + +func registerAdminHelpRoutes(r gin.IRouter, store *store) { + r.GET("/help", func(c *gin.Context) { + item, err := latestHelpContentDTO(store) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"item": item}) + }) + r.POST("/help", func(c *gin.Context) { + var req helpContentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"}) + return + } + claims := c.MustGet("admin_claims").(*sessionClaims) + row, err := store.InsertHelpContent(req.Markdown, claims.Username) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + item, err := helpContentDTO(row.ID, row.Markdown, row.CreatedBy, &row.CreatedAt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"item": item}) + }) + r.POST("/help/preview", func(c *gin.Context) { + var req helpContentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"}) + return + } + html, err := renderHelpMarkdown(req.Markdown) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"html": html}) + }) +} + +func latestHelpContentDTO(store *store) (gin.H, error) { + row, err := store.GetLatestHelpContent() + if errors.Is(err, gorm.ErrRecordNotFound) { + return helpContentDTO(0, defaultHelpMarkdown, "", nil) + } + if err != nil { + return nil, err + } + return helpContentDTO(row.ID, row.Markdown, row.CreatedBy, &row.CreatedAt) +} + +func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) { + html, err := renderHelpMarkdown(markdown) + if err != nil { + return nil, err + } + return gin.H{"id": ptrHelpID(id), "markdown": markdown, "html": html, "created_by": createdBy, "created_at": ptrTime(createdAt)}, nil +} + +func ptrHelpID(id uint64) any { + if id == 0 { + return nil + } + return id +} + +func ptrTime(value *time.Time) any { + if value == nil { + return nil + } + return *value +} diff --git a/db.go b/db.go index 9453890..561561a 100644 --- a/db.go +++ b/db.go @@ -91,6 +91,17 @@ func (loginLogRecord) TableName() string { return "login_log" } +type helpContentRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Markdown string `gorm:"column:markdown;type:text;not null"` + CreatedBy string `gorm:"column:created_by;index"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"` +} + +func (helpContentRecord) TableName() string { + return "help_content" +} + type discardDetailsRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Topic string `gorm:"column:topic"` @@ -389,6 +400,7 @@ func (s *store) migrate() error { }{ {label: "users", model: &userRecord{}}, {label: "login_log", model: &loginLogRecord{}}, + {label: "help_content", model: &helpContentRecord{}}, {label: "discard_details", model: &discardDetailsRecord{}}, {label: "node_blocking", model: &nodeBlockingRecord{}}, {label: "ip_blocking", model: &ipBlockingRecord{}}, diff --git a/go.mod b/go.mod index 4fa472b..6e26832 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -30,6 +31,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -37,6 +39,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -47,6 +50,7 @@ require ( github.com/rs/xid v1.4.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yuin/goldmark v1.8.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/go.sum b/go.sum index 2fb93ad..dfa6ffb 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -46,6 +48,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -68,6 +72,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -106,6 +112,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/help_markdown.go b/help_markdown.go new file mode 100644 index 0000000..d0d752f --- /dev/null +++ b/help_markdown.go @@ -0,0 +1,21 @@ +package main + +import ( + "bytes" + "fmt" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" +) + +func renderHelpMarkdown(markdown string) (string, error) { + var buf bytes.Buffer + md := goldmark.New(goldmark.WithExtensions(extension.GFM)) + if err := md.Convert([]byte(markdown), &buf); err != nil { + return "", fmt.Errorf("render markdown: %w", err) + } + policy := bluemonday.UGCPolicy() + policy.RequireNoFollowOnLinks(false) + return policy.Sanitize(buf.String()), nil +} diff --git a/help_store.go b/help_store.go new file mode 100644 index 0000000..ac785d3 --- /dev/null +++ b/help_store.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "strings" +) + +const maxHelpMarkdownBytes = 200 * 1024 + +const defaultHelpMarkdown = `## 连接地址 + +将 Meshtastic 设备连接到本服务提供的 MQTT broker。 + +- 默认地址:**mesh.gat-iot.com** +- 默认端口:**1883** +- 用户名称:**meshdev** +- 密码:**large4cats** + +## 频道加密要求 + +为了让服务能够解析 Meshtastic MQTT payload,频道需要满足以下任一条件: + +- 频道不加密。 +- 使用 Meshtastic 默认 PSK:**AQ==**。 + +如果使用自定义加密密钥,数据可能会被判定为无法解密并丢弃。 + +## 反馈问题 + +如果遇到 bug,请在 GitHub [提交 issue](https://github.com/wuwenfengmi1998/meshtastic_mqtt_server),或联系邮箱 [kevin@lmve.net](mailto:kevin@lmve.net)。` + +func (s *store) GetLatestHelpContent() (*helpContentRecord, error) { + var row helpContentRecord + if err := s.db.Order("id DESC").Take(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecord, error) { + markdown = strings.TrimSpace(markdown) + createdBy = strings.TrimSpace(createdBy) + if markdown == "" { + return nil, fmt.Errorf("markdown is required") + } + if len([]byte(markdown)) > maxHelpMarkdownBytes { + return nil, fmt.Errorf("markdown exceeds %d bytes", maxHelpMarkdownBytes) + } + row := helpContentRecord{Markdown: markdown, CreatedBy: createdBy} + if err := s.db.Create(&row).Error; err != nil { + return nil, err + } + return &row, nil +} diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index f08297c..dfc9038 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -4,6 +4,7 @@ import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, get import AdminBlockingManagement from './components/AdminBlockingManagement.vue' import AdminDashboard from './components/AdminDashboard.vue' import AdminDiscardDetails from './components/AdminDiscardDetails.vue' +import AdminHelpEdit from './components/AdminHelpEdit.vue' import AdminLogin from './components/AdminLogin.vue' import AdminLoginLogs from './components/AdminLoginLogs.vue' import AdminMqttForward from './components/AdminMqttForward.vue' @@ -457,6 +458,7 @@ onBeforeUnmount(() => { 用户管理 屏蔽管理 MQTT转发 + 帮助编辑 登录日志 丢弃数据 @@ -495,6 +497,7 @@ onBeforeUnmount(() => { + diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 68c9d5b..0119889 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -9,6 +9,8 @@ import type { ForbiddenWordBlockingRule, ForbiddenWordBlockingRulePayload, HealthStatus, + HelpContentResponse, + HelpPreviewResponse, IPBlockingRule, IPBlockingRulePayload, ListResponse, @@ -82,6 +84,10 @@ export function getHealth(): Promise { return getJSON('/api/health') } +export function getHelpContent(): Promise { + return getJSON('/api/help') +} + export function getNodeInfo(limit = 500, offset = 0): Promise> { return getJSON>(listPath('/api/nodeinfo', limit, offset)) } @@ -157,6 +163,18 @@ export function getAdminMqttStatus(): Promise { return getJSON('/api/admin/mqtt/status') } +export function getAdminHelpContent(): Promise { + return getJSON('/api/admin/help') +} + +export function saveAdminHelpContent(markdown: string): Promise { + return postJSON('/api/admin/help', { markdown }) +} + +export function previewAdminHelpContent(markdown: string): Promise { + return postJSON('/api/admin/help/preview', { markdown }) +} + export function getAdminUsers(): Promise { return getJSON('/api/admin/users') } diff --git a/meshmap_frontend/src/components/AdminHelpEdit.vue b/meshmap_frontend/src/components/AdminHelpEdit.vue new file mode 100644 index 0000000..8b98829 --- /dev/null +++ b/meshmap_frontend/src/components/AdminHelpEdit.vue @@ -0,0 +1,117 @@ + + + diff --git a/meshmap_frontend/src/components/HelpPage.vue b/meshmap_frontend/src/components/HelpPage.vue index 71a4c55..d889b9a 100644 --- a/meshmap_frontend/src/components/HelpPage.vue +++ b/meshmap_frontend/src/components/HelpPage.vue @@ -1,3 +1,27 @@ + +