forked from kevin/meshtastic_mqtt_server
安全加固:console_log.meshtastic 默认关闭(不打印解密明文),session_secure 默认 true,config.yaml 回写权限改 0600,公开接口错误信息脱敏(/api/health、/api/channels、列表接口,text-messages 移除 mqtt_remote_host),bot PSK 不回显(psk_set+更新保持原值),前端 help 页 DOMPurify 客户端消毒,后端 v1.6.0
This commit is contained in:
@@ -174,10 +174,12 @@ web:
|
|||||||
username: admin
|
username: admin
|
||||||
password: admin
|
password: admin
|
||||||
session_secret: ""
|
session_secret: ""
|
||||||
session_secure: false
|
session_secure: true
|
||||||
```
|
```
|
||||||
|
|
||||||
> 生产环境请修改默认管理员密码(非回环监听时仍为默认值 `admin` 将拒绝启动),并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。
|
> 生产环境请修改默认管理员密码(非回环监听时仍为默认值 `admin` 将拒绝启动),并设置足够长、随机的 `session_secret`。`session_secure` 默认为 `true`(HTTPS 部署适用),纯 HTTP 部署需改为 `false` 否则登录 cookie 不会回传。
|
||||||
|
|
||||||
|
> `console_log.meshtastic` 默认 `false`:解码后的 Meshtastic 数据包可能包含私聊明文,默认不打印;调试时可显式开启。
|
||||||
|
|
||||||
## 使用 SQLite 部署
|
## 使用 SQLite 部署
|
||||||
|
|
||||||
|
|||||||
@@ -81,12 +81,12 @@
|
|||||||
|
|
||||||
## P3 - 低优先(择机)
|
## P3 - 低优先(择机)
|
||||||
|
|
||||||
- [ ] **T14 解密私聊默认不打控制台日志** - `internal/config/config.go:204-210` 将 `console_log.meshtastic` 默认改 false,或至少对 `text_message` 且 DM 来源脱敏(`main.go:239-241`)
|
- [x] **T14 解密内容默认不打控制台日志(2026-08-20 完成)** - `console_log.meshtastic` 默认改 false(新部署);install.sh 模板同步;README 注明调试可显式开启。既有配置显式写 true 的不受影响
|
||||||
- [ ] **T15 session cookie `Secure: true`** - 生产部署 HTTPS 下设置 `session_secure: true`(`install.sh:94` / config 默认值);确认 nginx 强制 HTTP->HTTPS 跳转
|
- [x] **T15 session cookie `Secure: true` 默认(2026-08-20 完成)** - config 默认与 install.sh 模板改 `session_secure: true`(HTTPS 部署适用);纯 HTTP 部署需显式改回 false;README 注明
|
||||||
- [ ] **T16 config.yaml 回写权限** - `internal/config/config.go:573-582` `Write` 改 0600,避免明文密码 0644 可读
|
- [x] **T16 config.yaml 回写权限(2026-08-20 完成)** - `config.Write` 改 0600,配置文件含明文口令仅属主可读 ✓(冒烟验证)
|
||||||
- [ ] **T17 公开接口错误信息脱敏** - `/api/health` 等公开路由将 `err.Error()` 映射为固定文案(`webutil.go:178,191`)
|
- [x] **T17 公开接口错误信息脱敏(2026-08-20 完成)** - `webutil.WriteListResponse*` 统一返回 "internal error" 并 stderr 记详情;`/api/health`、`/api/channels` 同处理;`/api/text-messages` 移除 `mqtt_remote_host`(与 T6 同源泄露)
|
||||||
- [ ] **T18 bot PSK 不回显** - `internal/bot/admin_bot_routes.go:308` 改为 `psk_set` 布尔,与 forwarder 路由风格一致
|
- [x] **T18 bot PSK 不回显(2026-08-20 完成)** - bot DTO 改 `psk_set` 布尔;`UpdateBotNode` 空 PSK 保持原值(避免改配置时密钥被重置为 AQ==);前端编辑表单留空保持不变 ✓(冒烟验证)
|
||||||
- [ ] **T19 前端 help 页防御性消毒** - `meshmap_frontend/src/components/HelpPage.vue:38` 的 `v-html` 前增加 DOMPurify(纵深防御,当前依赖服务端 bluemonday)
|
- [x] **T19 前端 help 页 DOMPurify 消毒(2026-08-20 完成)** - `HelpPage` 与 `AdminHelpEdit` 的 v-html 前增加 `DOMPurify.sanitize`(纵深防御,服务端 bluemonday 之上);新增依赖 `dompurify`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -104,14 +104,16 @@ database:
|
|||||||
admin:
|
admin:
|
||||||
username: admin
|
username: admin
|
||||||
password: ${ADMIN_PASSWORD}
|
password: ${ADMIN_PASSWORD}
|
||||||
session_secret: ""
|
session_secret: ""
|
||||||
session_secure: false
|
# 前端经 HTTPS(nginx 反代)访问时保持 true;纯 HTTP 部署需改回 false
|
||||||
|
session_secure: true
|
||||||
console_log:
|
console_log:
|
||||||
web: true
|
web: true
|
||||||
mqtt: true
|
mqtt: true
|
||||||
llm: true
|
llm: true
|
||||||
sql: true
|
sql: true
|
||||||
meshtastic: true
|
# 默认不打印解码后的 Meshtastic 数据包(含私聊明文),调试时改回 true
|
||||||
|
meshtastic: false
|
||||||
EOF
|
EOF
|
||||||
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${CONFIG_DIR}/config.yaml"
|
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${CONFIG_DIR}/config.yaml"
|
||||||
chmod 0640 "${CONFIG_DIR}/config.yaml"
|
chmod 0640 "${CONFIG_DIR}/config.yaml"
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotN
|
|||||||
}
|
}
|
||||||
|
|
||||||
func botNodeDTO(row storepkg.BotNodeRecord) gin.H {
|
func botNodeDTO(row storepkg.BotNodeRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk_set": row.PSK != "", "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func botMessageDTO(row storepkg.BotMessageRecord) gin.H {
|
func botMessageDTO(row storepkg.BotMessageRecord) gin.H {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func Default() *Config {
|
|||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: "admin",
|
Password: "admin",
|
||||||
SessionSecret: "",
|
SessionSecret: "",
|
||||||
SessionSecure: false,
|
SessionSecure: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AI: AIConfig{
|
AI: AIConfig{
|
||||||
@@ -245,7 +245,9 @@ func Default() *Config {
|
|||||||
MQTT: true,
|
MQTT: true,
|
||||||
LLM: true,
|
LLM: true,
|
||||||
SQL: true,
|
SQL: true,
|
||||||
Meshtastic: true,
|
// 默认不打印解码后的 Meshtastic 数据包:其中包含私聊明文。
|
||||||
|
// 需要调试时可显式开启 console_log.meshtastic。
|
||||||
|
Meshtastic: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -713,7 +715,8 @@ func Write(path string, cfg *Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode config file %s: %w", path, err)
|
return fmt.Errorf("encode config file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
// 0600:配置文件含明文口令/密钥,只允许属主读写。
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
return fmt.Errorf("write config file %s: %w", path, err)
|
return fmt.Errorf("write config file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ func (s *Store) UpdateBotNode(id uint64, input BotNodeInput) (*BotNodeRecord, er
|
|||||||
preserved := existing.NodeNum
|
preserved := existing.NodeNum
|
||||||
input.NodeNum = &preserved
|
input.NodeNum = &preserved
|
||||||
}
|
}
|
||||||
|
// PSK 不回显给前端后,更新表单不会携带原值;为空时保持现有 PSK,
|
||||||
|
// 避免用户只是改个名字就把频道密钥重置为默认 AQ==。
|
||||||
|
if strings.TrimSpace(input.PSK) == "" {
|
||||||
|
input.PSK = existing.PSK
|
||||||
|
}
|
||||||
row, err := s.normalizedBotNodeRecord(input)
|
row, err := s.normalizedBotNodeRecord(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+8
-4
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -85,7 +86,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackendVersion = "1.5.0"
|
const BackendVersion = "1.6.0"
|
||||||
|
|
||||||
var CommitVersion = "dev"
|
var CommitVersion = "dev"
|
||||||
|
|
||||||
@@ -93,8 +94,9 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
|||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
status := gin.H{"status": "ok", "database": "ok"}
|
status := gin.H{"status": "ok", "database": "ok"}
|
||||||
if err := store.Ping(); err != nil {
|
if err := store.Ping(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[web] health check database error: %v\n", err)
|
||||||
status["status"] = "error"
|
status["status"] = "error"
|
||||||
status["database"] = err.Error()
|
status["database"] = "unavailable"
|
||||||
c.JSON(http.StatusServiceUnavailable, status)
|
c.JSON(http.StatusServiceUnavailable, status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,7 +155,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
|||||||
r.GET("/channels", func(c *gin.Context) {
|
r.GET("/channels", func(c *gin.Context) {
|
||||||
rows, err := store.ListChannels()
|
rows, err := store.ListChannels()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
fmt.Fprintf(os.Stderr, "[web] list channels error: %v\n", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items := make([]gin.H, 0, len(rows))
|
items := make([]gin.H, 0, len(rows))
|
||||||
@@ -684,7 +687,8 @@ func mapReportClusterDTO(row storepkg.MapReportClusterRecord) gin.H {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func textMessageDTO(row storepkg.TextMessageRecord) gin.H {
|
func textMessageDTO(row storepkg.TextMessageRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
// 不含 mqtt_remote_host:公开接口不暴露发布者 IP(见安全修复 T6 同类处理)。
|
||||||
|
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H {
|
func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H {
|
||||||
|
|||||||
@@ -7,13 +7,15 @@
|
|||||||
package webutil
|
package webutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"meshtastic_mqtt_server/internal/store"
|
store "meshtastic_mqtt_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseListOptions 从请求中读取 limit / offset / since / until / node_id /
|
// ParseListOptions 从请求中读取 limit / offset / since / until / node_id /
|
||||||
@@ -175,7 +177,8 @@ func ParseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool)
|
|||||||
// WriteListResponse 把 rows 通过 convert 转成 gin.H 后包装成 {items, limit, offset}。
|
// WriteListResponse 把 rows 通过 convert 转成 gin.H 后包装成 {items, limit, offset}。
|
||||||
func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, err error, convert func(T) gin.H) {
|
func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, err error, convert func(T) gin.H) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
logQueryError(c, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items := make([]gin.H, 0, len(rows))
|
items := make([]gin.H, 0, len(rows))
|
||||||
@@ -188,7 +191,8 @@ func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions,
|
|||||||
// WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。
|
// WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。
|
||||||
func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) {
|
func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
logQueryError(c, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items := make([]gin.H, 0, len(rows))
|
items := make([]gin.H, 0, len(rows))
|
||||||
@@ -198,6 +202,11 @@ func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.List
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logQueryError 把查询错误详情打到 stderr,响应体只回固定文案,避免泄露库表结构。
|
||||||
|
func logQueryError(c *gin.Context, err error) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[web] %s %s query error: %v\n", c.Request.Method, c.Request.URL.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
// PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any,
|
// PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any,
|
||||||
// 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。
|
// 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。
|
||||||
func PtrString(value *string) any {
|
func PtrString(value *string) any {
|
||||||
|
|||||||
Generated
+17
@@ -8,6 +8,7 @@
|
|||||||
"name": "meshmap_frontend",
|
"name": "meshmap_frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.14",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
@@ -789,6 +790,13 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.7",
|
"version": "6.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||||
@@ -993,6 +1001,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
||||||
|
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.23.0",
|
"version": "5.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.14",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,18 +98,20 @@ watch(currentChannelID, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function botPayload(form: { node_num: string | number | null; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; psk?: string; nodeinfo_broadcast_enabled?: boolean; nodeinfo_broadcast_interval_seconds?: string | number; enabled: boolean }): BotNodePayload {
|
function botPayload(form: { node_num: string | number | null; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; psk?: string; nodeinfo_broadcast_enabled?: boolean; nodeinfo_broadcast_interval_seconds?: string | number; enabled: boolean }, isCreate = false): BotNodePayload {
|
||||||
// <input type="number"> 的 v-model 会把绑定值转成 number,而 number 上没有 trim,
|
// <input type="number"> 的 v-model 会把绑定值转成 number,而 number 上没有 trim,
|
||||||
// 直接调用会抛 "node_num.trim is not a function" 让保存失败。统一转成 string 再 trim。
|
// 直接调用会抛 "node_num.trim is not a function" 让保存失败。统一转成 string 再 trim。
|
||||||
const nodeNumText = form.node_num == null ? '' : String(form.node_num).trim()
|
const nodeNumText = form.node_num == null ? '' : String(form.node_num).trim()
|
||||||
const interval = Number(form.nodeinfo_broadcast_interval_seconds || 3600)
|
const interval = Number(form.nodeinfo_broadcast_interval_seconds || 3600)
|
||||||
|
const psk = form.psk?.trim() || ''
|
||||||
return {
|
return {
|
||||||
node_num: nodeNumText ? Number(nodeNumText) : null,
|
node_num: nodeNumText ? Number(nodeNumText) : null,
|
||||||
long_name: form.long_name.trim(),
|
long_name: form.long_name.trim(),
|
||||||
short_name: form.short_name.trim(),
|
short_name: form.short_name.trim(),
|
||||||
default_channel_id: form.default_channel_id.trim(),
|
default_channel_id: form.default_channel_id.trim(),
|
||||||
topic_prefix: form.topic_prefix?.trim() || 'msh/CN',
|
topic_prefix: form.topic_prefix?.trim() || 'msh/CN',
|
||||||
psk: form.psk?.trim() || 'AQ==',
|
// 创建时缺省 AQ==;更新时不携带 psk(后端保持原值,避免密钥被重置)。
|
||||||
|
...(psk || isCreate ? { psk: psk || 'AQ==' } : {}),
|
||||||
nodeinfo_broadcast_enabled: form.nodeinfo_broadcast_enabled ?? true,
|
nodeinfo_broadcast_enabled: form.nodeinfo_broadcast_enabled ?? true,
|
||||||
nodeinfo_broadcast_interval_seconds: Number.isFinite(interval) && interval > 0 ? interval : 3600,
|
nodeinfo_broadcast_interval_seconds: Number.isFinite(interval) && interval > 0 ? interval : 3600,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
@@ -123,7 +125,8 @@ function resetEdits() {
|
|||||||
short_name: bot.short_name,
|
short_name: bot.short_name,
|
||||||
default_channel_id: bot.default_channel_id,
|
default_channel_id: bot.default_channel_id,
|
||||||
topic_prefix: bot.topic_prefix,
|
topic_prefix: bot.topic_prefix,
|
||||||
psk: bot.psk || 'AQ==',
|
// PSK 不回显,编辑时留空表示保持不变。
|
||||||
|
psk: '',
|
||||||
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
||||||
nodeinfo_broadcast_interval_seconds: String(bot.nodeinfo_broadcast_interval_seconds || 3600),
|
nodeinfo_broadcast_interval_seconds: String(bot.nodeinfo_broadcast_interval_seconds || 3600),
|
||||||
enabled: bot.enabled,
|
enabled: bot.enabled,
|
||||||
@@ -253,7 +256,7 @@ async function createBot() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
message.value = ''
|
message.value = ''
|
||||||
try {
|
try {
|
||||||
await createBotNode(botPayload(newBot.value))
|
await createBotNode(botPayload(newBot.value, true))
|
||||||
newBot.value = { node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true }
|
newBot.value = { node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true }
|
||||||
message.value = '机器人已创建'
|
message.value = '机器人已创建'
|
||||||
await refreshBots()
|
await refreshBots()
|
||||||
@@ -503,7 +506,7 @@ onBeforeUnmount(() => {
|
|||||||
<label>短名称<input v-model="edits[bot.id].short_name" /></label>
|
<label>短名称<input v-model="edits[bot.id].short_name" /></label>
|
||||||
<label>默认频道<input v-model="edits[bot.id].default_channel_id" /></label>
|
<label>默认频道<input v-model="edits[bot.id].default_channel_id" /></label>
|
||||||
<label>MQTT 根地址<input v-model="edits[bot.id].topic_prefix" placeholder="msh/CN" /></label>
|
<label>MQTT 根地址<input v-model="edits[bot.id].topic_prefix" placeholder="msh/CN" /></label>
|
||||||
<label>频道密钥 PSK<input v-model="edits[bot.id].psk" placeholder="AQ==" /></label>
|
<label>频道密钥 PSK<input v-model="edits[bot.id].psk" placeholder="留空保持不变" /></label>
|
||||||
<label>NodeInfo 间隔秒数<input v-model="edits[bot.id].nodeinfo_broadcast_interval_seconds" type="number" min="60" /></label>
|
<label>NodeInfo 间隔秒数<input v-model="edits[bot.id].nodeinfo_broadcast_interval_seconds" type="number" min="60" /></label>
|
||||||
<label class="inline"><input v-model="edits[bot.id].nodeinfo_broadcast_enabled" type="checkbox" /> 定期广播 NodeInfo</label>
|
<label class="inline"><input v-model="edits[bot.id].nodeinfo_broadcast_enabled" type="checkbox" /> 定期广播 NodeInfo</label>
|
||||||
<label class="inline"><input v-model="edits[bot.id].enabled" type="checkbox" /> 启用</label>
|
<label class="inline"><input v-model="edits[bot.id].enabled" type="checkbox" /> 启用</label>
|
||||||
@@ -537,7 +540,7 @@ onBeforeUnmount(() => {
|
|||||||
<span><strong>{{ selectedBot.node_num }}</strong><small>Node Num</small></span>
|
<span><strong>{{ selectedBot.node_num }}</strong><small>Node Num</small></span>
|
||||||
<span><strong>{{ selectedBot.default_channel_id }}</strong><small>默认频道</small></span>
|
<span><strong>{{ selectedBot.default_channel_id }}</strong><small>默认频道</small></span>
|
||||||
<span><strong>{{ selectedBot.topic_prefix || 'msh/CN' }}</strong><small>MQTT 根地址</small></span>
|
<span><strong>{{ selectedBot.topic_prefix || 'msh/CN' }}</strong><small>MQTT 根地址</small></span>
|
||||||
<span><strong>{{ selectedBot.psk || 'AQ==' }}</strong><small>频道 PSK</small></span>
|
<span><strong>{{ selectedBot.psk_set ? '已配置' : '默认' }}</strong><small>频道 PSK</small></span>
|
||||||
<span><strong>{{ selectedBot.private_key_set ? '已生成' : '未生成' }}</strong><small>机器人密钥</small></span>
|
<span><strong>{{ selectedBot.private_key_set ? '已生成' : '未生成' }}</strong><small>机器人密钥</small></span>
|
||||||
<span class="public-key"><strong>{{ selectedBot.public_key || '-' }}</strong><small>Public Key</small></span>
|
<span class="public-key"><strong>{{ selectedBot.public_key || '-' }}</strong><small>Public Key</small></span>
|
||||||
<span><strong>{{ selectedBot.nodeinfo_broadcast_enabled ? `${selectedBot.nodeinfo_broadcast_interval_seconds}s` : '关闭' }}</strong><small>NodeInfo 广播</small></span>
|
<span><strong>{{ selectedBot.nodeinfo_broadcast_enabled ? `${selectedBot.nodeinfo_broadcast_interval_seconds}s` : '关闭' }}</strong><small>NodeInfo 广播</small></span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { getAdminHelpContent, previewAdminHelpContent, saveAdminHelpContent } from '../api'
|
import { getAdminHelpContent, previewAdminHelpContent, saveAdminHelpContent } from '../api'
|
||||||
import type { HelpContent } from '../types'
|
import type { HelpContent } from '../types'
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ async function loadHelpContent() {
|
|||||||
const response = await getAdminHelpContent()
|
const response = await getAdminHelpContent()
|
||||||
latest.value = response.item
|
latest.value = response.item
|
||||||
markdown.value = response.item.markdown
|
markdown.value = response.item.markdown
|
||||||
previewHtml.value = response.item.html
|
previewHtml.value = DOMPurify.sanitize(response.item.html)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,7 +39,7 @@ async function previewHelpContent() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const response = await previewAdminHelpContent(markdown.value)
|
const response = await previewAdminHelpContent(markdown.value)
|
||||||
previewHtml.value = response.html
|
previewHtml.value = DOMPurify.sanitize(response.html)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -67,7 +68,7 @@ async function saveHelpContent() {
|
|||||||
const response = await saveAdminHelpContent(markdown.value)
|
const response = await saveAdminHelpContent(markdown.value)
|
||||||
latest.value = response.item
|
latest.value = response.item
|
||||||
markdown.value = response.item.markdown
|
markdown.value = response.item.markdown
|
||||||
previewHtml.value = response.item.html
|
previewHtml.value = DOMPurify.sanitize(response.item.html)
|
||||||
message.value = `帮助内容已保存为版本 #${response.item.id}`
|
message.value = `帮助内容已保存为版本 #${response.item.id}`
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function toggleBotLLMQueue(bot: BotNode) {
|
|||||||
enabled: bot.enabled,
|
enabled: bot.enabled,
|
||||||
default_channel_id: bot.default_channel_id,
|
default_channel_id: bot.default_channel_id,
|
||||||
topic_prefix: bot.topic_prefix,
|
topic_prefix: bot.topic_prefix,
|
||||||
psk: bot.psk,
|
|
||||||
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
||||||
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
|
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
|
||||||
llm_queue_enabled: !bot.llm_queue_enabled,
|
llm_queue_enabled: !bot.llm_queue_enabled,
|
||||||
@@ -71,7 +71,7 @@ async function toggleBotIncludeChannel(bot: BotNode) {
|
|||||||
enabled: bot.enabled,
|
enabled: bot.enabled,
|
||||||
default_channel_id: bot.default_channel_id,
|
default_channel_id: bot.default_channel_id,
|
||||||
topic_prefix: bot.topic_prefix,
|
topic_prefix: bot.topic_prefix,
|
||||||
psk: bot.psk,
|
|
||||||
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
||||||
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
|
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
|
||||||
llm_queue_enabled: bot.llm_queue_enabled,
|
llm_queue_enabled: bot.llm_queue_enabled,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { getHelpContent } from '../api'
|
import { getHelpContent } from '../api'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -11,7 +12,8 @@ async function loadHelpContent() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const response = await getHelpContent()
|
const response = await getHelpContent()
|
||||||
html.value = response.item.html
|
// 服务端已用 bluemonday 消毒,这里再用 DOMPurify 做客户端纵深防御。
|
||||||
|
html.value = DOMPurify.sanitize(response.item.html)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -525,7 +525,7 @@ export interface BotNode {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
default_channel_id: string
|
default_channel_id: string
|
||||||
topic_prefix: string
|
topic_prefix: string
|
||||||
psk: string
|
psk_set: boolean
|
||||||
public_key: string
|
public_key: string
|
||||||
private_key_set: boolean
|
private_key_set: boolean
|
||||||
nodeinfo_broadcast_enabled: boolean
|
nodeinfo_broadcast_enabled: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user