合并 refactor/internal-packages:把根目录 46 个 .go 文件按功能拆到 internal/ 下
把工程根目录全部 package main 文件按业务边界搬到 internal/ 子包下。 根目录从 46 个文件 12.6k 行降到 2 个文件 480 行;新增 13 个 internal 子包: auth、blocking、bot、config、help、llmadmin、mapsource、mqttforward、 runtimesettings、sign、store、store/testutil、web、webutil。 共 4 个 commit 渐进迁移,每个 commit 单独 build/test 通过: -eff4972拆出 internal/config 与 internal/store -c527a9f拆出 auth / blocking / runtimesettings / help / mqttforward -9394aa0拆出 bot / sign / mapsource / llmadmin / web -46916d9main.go 直接 import 子包并删除全部 bridge go build ./... 与 go test ./... 全部通过,测试数量与重构前一致。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,101 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pkiKeyResolver 是 mqtpp 在解密 PKI 加密包时回调的接收者私钥/发送者公钥查询函数。
|
|
||||||
//
|
|
||||||
// to 是接收者节点号(应该匹配某个本地受管的 bot),from 是发送者节点号(应该已经有 nodeinfo 上报)。
|
|
||||||
// 返回的 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。
|
|
||||||
func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
|
||||||
if s == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
|
||||||
bot, err := s.GetBotNodeByNodeNum(int64(toNodeNum))
|
|
||||||
if err != nil {
|
|
||||||
printJSON(map[string]any{
|
|
||||||
"event": "pki_resolve_bot_not_found",
|
|
||||||
"to_num": toNodeNum,
|
|
||||||
"from_num": fromNodeNum,
|
|
||||||
})
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
|
||||||
if privateKeyB64 == "" {
|
|
||||||
printJSON(map[string]any{
|
|
||||||
"event": "pki_resolve_no_private_key",
|
|
||||||
"bot_id": bot.NodeID,
|
|
||||||
"bot_num": bot.NodeNum,
|
|
||||||
"from_num": fromNodeNum,
|
|
||||||
})
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
|
||||||
if err != nil || len(privateKey) != 32 {
|
|
||||||
printJSON(map[string]any{
|
|
||||||
"event": "pki_resolve_invalid_private_key",
|
|
||||||
"bot_id": bot.NodeID,
|
|
||||||
"bot_num": bot.NodeNum,
|
|
||||||
"from_num": fromNodeNum,
|
|
||||||
"error": err,
|
|
||||||
})
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
fromPublic, ok := lookupNodeInfoPublicKey(s, fromNodeNum)
|
|
||||||
if !ok {
|
|
||||||
printJSON(map[string]any{
|
|
||||||
"event": "pki_resolve_no_sender_public_key",
|
|
||||||
"bot_id": bot.NodeID,
|
|
||||||
"bot_num": bot.NodeNum,
|
|
||||||
"from_num": fromNodeNum,
|
|
||||||
})
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
return privateKey, fromPublic, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookupNodeInfoPublicKey 在 nodeinfo 表中按 node_num 查 X25519 公钥,
|
|
||||||
// 兼容 hex 与 base64 两种历史存储格式。
|
|
||||||
func lookupNodeInfoPublicKey(s *store, nodeNum uint32) ([]byte, bool) {
|
|
||||||
var row nodeInfoRecord
|
|
||||||
if err := s.db.Where("node_num = ?", int64(nodeNum)).Take(&row).Error; err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if row.PublicKey == nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
value := strings.TrimSpace(*row.PublicKey)
|
|
||||||
if value == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == 32 {
|
|
||||||
return decoded, true
|
|
||||||
}
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == 32 {
|
|
||||||
return decoded, true
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBotNodeByNodeNum 按节点号查找受管 bot 节点;用于 PKI 解密时把 to 字段映射回本地私钥。
|
|
||||||
func (s *store) GetBotNodeByNodeNum(nodeNum int64) (*botNodeRecord, error) {
|
|
||||||
if s == nil || s.db == nil {
|
|
||||||
return nil, errors.New("store not configured")
|
|
||||||
}
|
|
||||||
var row botNodeRecord
|
|
||||||
if err := s.db.Where("node_num = ?", nodeNum).Take(&row).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &row, nil
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
package main
|
// Package auth 实现 admin 端 cookie session 与密码散列。
|
||||||
|
//
|
||||||
|
// 拆离自原来 main 包的 auth.go:让所有 admin route 包都可以直接 import 这里的
|
||||||
|
// SessionClaims / Manager / RequireAdmin,而不是被锁在根 main 包里。
|
||||||
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
@@ -14,44 +18,56 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/config"
|
||||||
|
"meshtastic_mqtt_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adminRole = "admin"
|
AdminRole = "admin"
|
||||||
adminSessionCookie = "mesh_admin_session"
|
adminSessionCookie = "mesh_admin_session"
|
||||||
|
|
||||||
|
// AdminClaimsKey 是中间件挂在 gin.Context 上的 key,handler 可用
|
||||||
|
// `c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)` 取出。
|
||||||
|
AdminClaimsKey = "admin_claims"
|
||||||
)
|
)
|
||||||
|
|
||||||
type adminUserDTO struct {
|
// AdminUserDTO 是 /me /login 等接口返回给前端的最小用户视图。
|
||||||
|
type AdminUserDTO struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionClaims struct {
|
// SessionClaims 是 cookie 中持久化的会话内容。
|
||||||
|
type SessionClaims struct {
|
||||||
UserID uint64 `json:"user_id"`
|
UserID uint64 `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Expires int64 `json:"expires"`
|
Expires int64 `json:"expires"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionManager struct {
|
// Manager 持有签名密钥与 cookie 配置,是发布 / 校验 cookie 的入口。
|
||||||
|
type Manager struct {
|
||||||
secret []byte
|
secret []byte
|
||||||
secure bool
|
secure bool
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSessionManager(cfg webAdminConfig) (*sessionManager, error) {
|
// NewManager 根据配置构造 Manager。如果 SessionSecret 空,会随机生成 32 字节。
|
||||||
|
func NewManager(cfg config.WebAdminConfig) (*Manager, error) {
|
||||||
secret := strings.TrimSpace(cfg.SessionSecret)
|
secret := strings.TrimSpace(cfg.SessionSecret)
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
generated := make([]byte, 32)
|
generated := make([]byte, 32)
|
||||||
if _, err := rand.Read(generated); err != nil {
|
if _, err := rand.Read(generated); err != nil {
|
||||||
return nil, fmt.Errorf("generate admin session secret: %w", err)
|
return nil, fmt.Errorf("generate admin session secret: %w", err)
|
||||||
}
|
}
|
||||||
return &sessionManager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
return &Manager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||||
}
|
}
|
||||||
return &sessionManager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
return &Manager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashPassword(password string) (string, error) {
|
// HashPassword 用 bcrypt 默认 cost 散列;用于建账号、改密码。
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -59,16 +75,19 @@ func hashPassword(password string) (string, error) {
|
|||||||
return string(hash), nil
|
return string(hash), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyPassword(hash, password string) bool {
|
// VerifyPassword 校验明文密码是否与散列匹配。
|
||||||
|
func VerifyPassword(hash, password string) bool {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminUserResponse(user userRecord) adminUserDTO {
|
// AdminUserResponse 把 store.UserRecord 转成对外 DTO。
|
||||||
return adminUserDTO{Username: user.Username, Role: user.Role}
|
func AdminUserResponse(user store.UserRecord) AdminUserDTO {
|
||||||
|
return AdminUserDTO{Username: user.Username, Role: user.Role}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *sessionManager) newCookie(user userRecord) (*http.Cookie, error) {
|
// NewCookie 为已登录用户构造一份带签名的 session cookie。
|
||||||
claims := sessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()}
|
func (sm *Manager) NewCookie(user store.UserRecord) (*http.Cookie, error) {
|
||||||
|
claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()}
|
||||||
data, err := json.Marshal(claims)
|
data, err := json.Marshal(claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -86,7 +105,8 @@ func (sm *sessionManager) newCookie(user userRecord) (*http.Cookie, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *sessionManager) clearCookie() *http.Cookie {
|
// ClearCookie 返回一个把 cookie 立即清掉的 *http.Cookie,供 logout 使用。
|
||||||
|
func (sm *Manager) ClearCookie() *http.Cookie {
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: adminSessionCookie,
|
Name: adminSessionCookie,
|
||||||
Value: "",
|
Value: "",
|
||||||
@@ -98,7 +118,8 @@ func (sm *sessionManager) clearCookie() *http.Cookie {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *sessionManager) claimsFromRequest(c *gin.Context) (*sessionClaims, error) {
|
// ClaimsFromRequest 校验请求 cookie 的签名 / 过期 / 角色。
|
||||||
|
func (sm *Manager) ClaimsFromRequest(c *gin.Context) (*SessionClaims, error) {
|
||||||
cookie, err := c.Cookie(adminSessionCookie)
|
cookie, err := c.Cookie(adminSessionCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -114,34 +135,35 @@ func (sm *sessionManager) claimsFromRequest(c *gin.Context) (*sessionClaims, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var claims sessionClaims
|
var claims SessionClaims
|
||||||
if err := json.Unmarshal(data, &claims); err != nil {
|
if err := json.Unmarshal(data, &claims); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if claims.Expires <= time.Now().Unix() {
|
if claims.Expires <= time.Now().Unix() {
|
||||||
return nil, errors.New("session expired")
|
return nil, errors.New("session expired")
|
||||||
}
|
}
|
||||||
if claims.Role != adminRole {
|
if claims.Role != AdminRole {
|
||||||
return nil, errors.New("admin required")
|
return nil, errors.New("admin required")
|
||||||
}
|
}
|
||||||
return &claims, nil
|
return &claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *sessionManager) sign(payload string) string {
|
func (sm *Manager) sign(payload string) string {
|
||||||
mac := hmac.New(sha256.New, sm.secret)
|
mac := hmac.New(sha256.New, sm.secret)
|
||||||
mac.Write([]byte(payload))
|
mac.Write([]byte(payload))
|
||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireAdmin(sm *sessionManager) gin.HandlerFunc {
|
// RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。
|
||||||
|
func RequireAdmin(sm *Manager) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
claims, err := sm.claimsFromRequest(c)
|
claims, err := sm.ClaimsFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set("admin_claims", claims)
|
c.Set(AdminClaimsKey, claims)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package blocking
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type nodeBlockingRequest struct {
|
type nodeBlockingRequest struct {
|
||||||
@@ -30,7 +33,7 @@ type forbiddenWordBlockingRequest struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blockingCache) {
|
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, blocking *Cache) {
|
||||||
reloadBlocking := func() error {
|
reloadBlocking := func() error {
|
||||||
if blocking == nil {
|
if blocking == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -39,17 +42,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
|
|||||||
}
|
}
|
||||||
|
|
||||||
r.GET("/blocking/nodes", func(c *gin.Context) {
|
r.GET("/blocking/nodes", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListNodeBlocking(opts)
|
rows, err := store.ListNodeBlocking(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, nodeBlockingDTO)
|
webutil.WriteListResponse(c, rows, opts, err, nodeBlockingDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountNodeBlocking(opts)
|
total, err := store.CountNodeBlocking(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO)
|
||||||
})
|
})
|
||||||
r.POST("/blocking/nodes", func(c *gin.Context) {
|
r.POST("/blocking/nodes", func(c *gin.Context) {
|
||||||
var req nodeBlockingRequest
|
var req nodeBlockingRequest
|
||||||
@@ -82,17 +85,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
|
|||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/blocking/ips", func(c *gin.Context) {
|
r.GET("/blocking/ips", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListIPBlocking(opts)
|
rows, err := store.ListIPBlocking(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, ipBlockingDTO)
|
webutil.WriteListResponse(c, rows, opts, err, ipBlockingDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountIPBlocking(opts)
|
total, err := store.CountIPBlocking(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO)
|
||||||
})
|
})
|
||||||
r.POST("/blocking/ips", func(c *gin.Context) {
|
r.POST("/blocking/ips", func(c *gin.Context) {
|
||||||
var req ipBlockingRequest
|
var req ipBlockingRequest
|
||||||
@@ -125,17 +128,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
|
|||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/blocking/words", func(c *gin.Context) {
|
r.GET("/blocking/words", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListForbiddenWordBlocking(opts)
|
rows, err := store.ListForbiddenWordBlocking(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, forbiddenWordBlockingDTO)
|
webutil.WriteListResponse(c, rows, opts, err, forbiddenWordBlockingDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountForbiddenWordBlocking(opts)
|
total, err := store.CountForbiddenWordBlocking(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO)
|
||||||
})
|
})
|
||||||
r.POST("/blocking/words", func(c *gin.Context) {
|
r.POST("/blocking/words", func(c *gin.Context) {
|
||||||
var req forbiddenWordBlockingRequest
|
var req forbiddenWordBlockingRequest
|
||||||
@@ -178,7 +181,7 @@ func parseBlockingID(c *gin.Context) (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H, afterSuccess func() error) {
|
func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H, afterSuccess func() error) {
|
||||||
if errors.Is(err, errBlockingAlreadyExists) {
|
if errors.Is(err, storepkg.ErrBlockingAlreadyExists) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,14 +220,14 @@ func writeBlockingDeleteResponse(c *gin.Context, err error, afterSuccess func()
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func nodeBlockingDTO(row nodeBlockingRecord) gin.H {
|
func nodeBlockingDTO(row storepkg.NodeBlockingRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": ptrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": webutil.PtrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipBlockingDTO(row ipBlockingRecord) gin.H {
|
func ipBlockingDTO(row storepkg.IPBlockingRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "ip_value": row.IPValue, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
return gin.H{"id": row.ID, "ip_value": row.IPValue, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func forbiddenWordBlockingDTO(row forbiddenWordBlockingRecord) gin.H {
|
func forbiddenWordBlockingDTO(row storepkg.ForbiddenWordBlockingRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "word": row.Word, "match_type": row.MatchType, "case_sensitive": row.CaseSensitive, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
return gin.H{"id": row.ID, "word": row.Word, "match_type": row.MatchType, "case_sensitive": row.CaseSensitive, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package blocking
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type blockingCache struct {
|
type Cache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
nodes map[string]struct{}
|
nodes map[string]struct{}
|
||||||
nodeNums map[int64]struct{}
|
nodeNums map[int64]struct{}
|
||||||
@@ -24,15 +26,15 @@ type forbiddenWordRule struct {
|
|||||||
caseSensitive bool
|
caseSensitive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBlockingCache(store *store) (*blockingCache, error) {
|
func New(store *storepkg.Store) (*Cache, error) {
|
||||||
cache := &blockingCache{}
|
cache := &Cache{}
|
||||||
if err := cache.Reload(store); err != nil {
|
if err := cache.Reload(store); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return cache, nil
|
return cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *blockingCache) Reload(store *store) error {
|
func (c *Cache) Reload(store *storepkg.Store) error {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return fmt.Errorf("store is required")
|
return fmt.Errorf("store is required")
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,7 @@ func (c *blockingCache) Reload(store *store) error {
|
|||||||
words := make([]forbiddenWordRule, 0, len(wordRows))
|
words := make([]forbiddenWordRule, 0, len(wordRows))
|
||||||
for _, row := range wordRows {
|
for _, row := range wordRows {
|
||||||
word := strings.TrimSpace(row.Word)
|
word := strings.TrimSpace(row.Word)
|
||||||
if word == "" || row.MatchType != forbiddenWordMatchContains {
|
if word == "" || row.MatchType != storepkg.ForbiddenWordMatchContains {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
words = append(words, forbiddenWordRule{word: word, foldedWord: strings.ToLower(word), matchType: row.MatchType, caseSensitive: row.CaseSensitive})
|
words = append(words, forbiddenWordRule{word: word, foldedWord: strings.ToLower(word), matchType: row.MatchType, caseSensitive: row.CaseSensitive})
|
||||||
@@ -97,7 +99,7 @@ func (c *blockingCache) Reload(store *store) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool {
|
func (c *Cache) IsNodeBlocked(nodeID any, nodeNum any) bool {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -118,7 +120,7 @@ func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *blockingCache) IsIPBlocked(host string) bool {
|
func (c *Cache) IsIPBlocked(host string) bool {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -144,7 +146,7 @@ func (c *blockingCache) IsIPBlocked(host string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *blockingCache) FindForbiddenWord(text any) (string, bool) {
|
func (c *Cache) FindForbiddenWord(text any) (string, bool) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -157,7 +159,7 @@ func (c *blockingCache) FindForbiddenWord(text any) (string, bool) {
|
|||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
foldedText := ""
|
foldedText := ""
|
||||||
for _, rule := range c.words {
|
for _, rule := range c.words {
|
||||||
if rule.matchType != forbiddenWordMatchContains {
|
if rule.matchType != storepkg.ForbiddenWordMatchContains {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rule.caseSensitive {
|
if rule.caseSensitive {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package blocking
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -27,9 +27,9 @@ func TestBlockingCacheLoadsEnabledRules(t *testing.T) {
|
|||||||
t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err)
|
t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cache, err := newBlockingCache(st)
|
cache, err := New(st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newBlockingCache() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.IsNodeBlocked("!12345678", nil) {
|
if !cache.IsNodeBlocked("!12345678", nil) {
|
||||||
@@ -65,9 +65,9 @@ func TestBlockingCacheIPExactAndCIDR(t *testing.T) {
|
|||||||
if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil {
|
if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil {
|
||||||
t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err)
|
t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err)
|
||||||
}
|
}
|
||||||
cache, err := newBlockingCache(st)
|
cache, err := New(st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newBlockingCache() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.IsIPBlocked("127.0.0.1") {
|
if !cache.IsIPBlocked("127.0.0.1") {
|
||||||
@@ -88,9 +88,9 @@ func TestBlockingCacheForbiddenWordCaseSensitivity(t *testing.T) {
|
|||||||
if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil {
|
if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil {
|
||||||
t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err)
|
t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err)
|
||||||
}
|
}
|
||||||
cache, err := newBlockingCache(st)
|
cache, err := New(st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newBlockingCache() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := cache.FindForbiddenWord("lowercase spam"); ok {
|
if _, ok := cache.FindForbiddenWord("lowercase spam"); ok {
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package blocking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/store/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openTestStore 委托到 store/testutil,让本包的测试代码保持简洁。
|
||||||
|
func openTestStore(t *testing.T) *store.Store {
|
||||||
|
return testutil.OpenStore(t)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package bot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -7,6 +7,10 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/auth"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type botNodeRequest struct {
|
type botNodeRequest struct {
|
||||||
@@ -32,19 +36,19 @@ type botSendMessageRequest struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, sender TextSender) {
|
||||||
r.GET("/bot/nodes", func(c *gin.Context) {
|
r.GET("/bot/nodes", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListBotNodes(opts)
|
rows, err := store.ListBotNodes(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, botNodeDTO)
|
webutil.WriteListResponse(c, rows, opts, err, botNodeDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountBotNodes(opts)
|
total, err := store.CountBotNodes(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
|
||||||
})
|
})
|
||||||
r.POST("/bot/nodes", func(c *gin.Context) {
|
r.POST("/bot/nodes", func(c *gin.Context) {
|
||||||
var req botNodeRequest
|
var req botNodeRequest
|
||||||
@@ -125,14 +129,14 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
|||||||
}
|
}
|
||||||
rows, err := store.ListBotMessages(opts)
|
rows, err := store.ListBotMessages(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts.listOptions, err, botMessageDTO)
|
webutil.WriteListResponse(c, rows, opts.ListOptions, err, botMessageDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountBotMessages(opts)
|
total, err := store.CountBotMessages(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts.listOptions, total, err, botMessageDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO)
|
||||||
})
|
})
|
||||||
r.GET("/bot/direct-messages", func(c *gin.Context) {
|
r.GET("/bot/direct-messages", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,19 +157,19 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dmOpts := botDirectMessageListOptions{listOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
|
dmOpts := storepkg.BotDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
|
||||||
rows, err := store.ListBotDirectMessagesByConversation(dmOpts)
|
rows, err := store.ListBotDirectMessagesByConversation(dmOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, botDirectMessageDTO)
|
webutil.WriteListResponse(c, rows, opts, err, botDirectMessageDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
|
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
|
||||||
})
|
})
|
||||||
// /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数),
|
// /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数),
|
||||||
// 给前端侧边栏渲染会话列表使用。
|
// 给前端侧边栏渲染会话列表使用。
|
||||||
r.GET("/bot/conversations", func(c *gin.Context) {
|
r.GET("/bot/conversations", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -235,8 +239,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
|
||||||
row, err := sender.SendText(c.Request.Context(), botSendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username})
|
row, err := sender.SendText(c.Request.Context(), SendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username})
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||||
return
|
return
|
||||||
@@ -254,8 +258,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func botNodeInputFromRequest(req botNodeRequest) botNodeInput {
|
func botNodeInputFromRequest(req botNodeRequest) storepkg.BotNodeInput {
|
||||||
return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages}
|
return storepkg.BotNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBotID(c *gin.Context, message string) (uint64, bool) {
|
func parseBotID(c *gin.Context, message string) (uint64, bool) {
|
||||||
@@ -267,25 +271,25 @@ func parseBotID(c *gin.Context, message string) (uint64, bool) {
|
|||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) {
|
func parseBotMessageListOptions(c *gin.Context) (storepkg.BotMessageListOptions, bool) {
|
||||||
listOpts, ok := parseListOptions(c)
|
listOpts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return botMessageListOptions{}, false
|
return storepkg.BotMessageListOptions{}, false
|
||||||
}
|
}
|
||||||
opts := botMessageListOptions{listOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")}
|
opts := storepkg.BotMessageListOptions{ListOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")}
|
||||||
if value := c.Query("bot_id"); value != "" {
|
if value := c.Query("bot_id"); value != "" {
|
||||||
id, err := strconv.ParseUint(value, 10, 64)
|
id, err := strconv.ParseUint(value, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||||
return botMessageListOptions{}, false
|
return storepkg.BotMessageListOptions{}, false
|
||||||
}
|
}
|
||||||
opts.BotID = id
|
opts.BotID = id
|
||||||
}
|
}
|
||||||
return opts, true
|
return opts, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord, err error) {
|
func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotNodeRecord, err error) {
|
||||||
if errors.Is(err, errBotNodeAlreadyExists) {
|
if errors.Is(err, storepkg.ErrBotNodeAlreadyExists) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"})
|
c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -300,15 +304,15 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord
|
|||||||
c.JSON(status, gin.H{"item": botNodeDTO(*row)})
|
c.JSON(status, gin.H{"item": botNodeDTO(*row)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func botNodeDTO(row 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": 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 botMessageRecord) gin.H {
|
func botMessageDTO(row storepkg.BotMessageRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "bot_id": row.BotID, "bot_node_id": row.BotNodeID, "bot_node_num": row.BotNodeNum, "message_type": row.MessageType, "channel_id": row.ChannelID, "to_node_id": row.ToNodeID, "to_node_num": row.ToNodeNum, "topic": row.Topic, "packet_id": row.PacketID, "text": row.Text, "payload_len": row.PayloadLen, "encrypted": row.Encrypted, "status": row.Status, "error": row.Error, "published_at": row.PublishedAt, "created_by": row.CreatedBy, "created_at": row.CreatedAt}
|
return gin.H{"id": row.ID, "bot_id": row.BotID, "bot_node_id": row.BotNodeID, "bot_node_num": row.BotNodeNum, "message_type": row.MessageType, "channel_id": row.ChannelID, "to_node_id": row.ToNodeID, "to_node_num": row.ToNodeNum, "topic": row.Topic, "packet_id": row.PacketID, "text": row.Text, "payload_len": row.PayloadLen, "encrypted": row.Encrypted, "status": row.Status, "error": row.Error, "published_at": row.PublishedAt, "created_by": row.CreatedBy, "created_at": row.CreatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
|
func botDirectMessageDTO(row storepkg.BotDirectMessageRecord) gin.H {
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"bot_id": row.BotID,
|
"bot_id": row.BotID,
|
||||||
@@ -335,7 +339,7 @@ func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func botDirectConversationDTO(row botDirectConversation) gin.H {
|
func botDirectConversationDTO(row storepkg.BotDirectConversation) gin.H {
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"bot_id": row.BotID,
|
"bot_id": row.BotID,
|
||||||
"peer_node_id": row.PeerNodeID,
|
"peer_node_id": row.PeerNodeID,
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者
|
||||||
|
// 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。
|
||||||
|
// 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。
|
||||||
|
func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||||
|
bot, err := s.GetBotNodeByNodeNum(int64(toNodeNum))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||||
|
if privateKeyB64 == "" {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||||
|
if err != nil || len(privateKey) != 32 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
fromPublic, ok := s.LookupNodeInfoPublicKey(fromNodeNum)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return privateKey, fromPublic, true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package bot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -12,15 +12,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"meshtastic_mqtt_server/mqtpp"
|
|
||||||
|
|
||||||
mqtt "github.com/mochi-mqtt/server/v2"
|
mqtt "github.com/mochi-mqtt/server/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/mqtpp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const botMaxTextBytes = 200
|
const botMaxTextBytes = 200
|
||||||
|
|
||||||
type botSendTextRequest struct {
|
type SendTextRequest struct {
|
||||||
BotID uint64
|
BotID uint64
|
||||||
MessageType string
|
MessageType string
|
||||||
ChannelID string
|
ChannelID string
|
||||||
@@ -30,22 +31,22 @@ type botSendTextRequest struct {
|
|||||||
CreatedBy string
|
CreatedBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
type botTextSender interface {
|
type TextSender interface {
|
||||||
SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error)
|
SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error)
|
||||||
PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error)
|
PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type botService struct {
|
type Service struct {
|
||||||
store *store
|
store *storepkg.Store
|
||||||
server *mqtt.Server
|
server *mqtt.Server
|
||||||
key []byte
|
key []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBotService(store *store, server *mqtt.Server, key []byte) *botService {
|
func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service {
|
||||||
return &botService{store: store, server: server, key: key}
|
return &Service{store: store, server: server, key: key}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
|
func (s *Service) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||||
if s == nil || s.store == nil || s.server == nil {
|
if s == nil || s.store == nil || s.server == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
|
|||||||
// - 其它情况用原 channel + bot PSK 加密
|
// - 其它情况用原 channel + bot PSK 加密
|
||||||
//
|
//
|
||||||
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。
|
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。
|
||||||
func (s *botService) MaybeAutoAck(record map[string]any) {
|
func (s *Service) MaybeAutoAck(record map[string]any) {
|
||||||
if s == nil || s.store == nil || s.server == nil || record == nil {
|
if s == nil || s.store == nil || s.server == nil || record == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ func (s *botService) MaybeAutoAck(record map[string]any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
|
func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
|
||||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||||
if privateKeyB64 == "" {
|
if privateKeyB64 == "" {
|
||||||
return nil, fmt.Errorf("bot has no private key")
|
return nil, fmt.Errorf("bot has no private key")
|
||||||
@@ -119,11 +120,11 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
senderPublic, err := decodeBotPublicKey(*bot)
|
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
recipientPublic, ok := lookupNodeInfoPublicKey(s.store, toNum)
|
recipientPublic, ok := s.store.LookupNodeInfoPublicKey(toNum)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("recipient %s has no public key on file", mqtpp.NodeNumToID(toNum))
|
return nil, fmt.Errorf("recipient %s has no public key on file", mqtpp.NodeNumToID(toNum))
|
||||||
}
|
}
|
||||||
@@ -140,14 +141,14 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) buildPSKAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
|
func (s *Service) buildPSKAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
|
||||||
channel := fallbackChannelID(channelID, false, bot.DefaultChannelID)
|
channel := fallbackChannelID(channelID, false, bot.DefaultChannelID)
|
||||||
if channel == "" || channel == mqtpp.PKIChannelID {
|
if channel == "" || channel == mqtpp.PKIChannelID {
|
||||||
return nil, fmt.Errorf("no channel id available for psk ack")
|
return nil, fmt.Errorf("no channel id available for psk ack")
|
||||||
}
|
}
|
||||||
psk := strings.TrimSpace(bot.PSK)
|
psk := strings.TrimSpace(bot.PSK)
|
||||||
if psk == "" {
|
if psk == "" {
|
||||||
psk = botDefaultPSK
|
psk = storepkg.BotDefaultPSK
|
||||||
}
|
}
|
||||||
key, err := mqtpp.ExpandPSK(psk)
|
key, err := mqtpp.ExpandPSK(psk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,7 +209,7 @@ func errString(err error) string {
|
|||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMessageRecord, error) {
|
func (s *Service) SendText(_ context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error) {
|
||||||
if s == nil || s.store == nil {
|
if s == nil || s.store == nil {
|
||||||
return nil, fmt.Errorf("bot service is not configured")
|
return nil, fmt.Errorf("bot service is not configured")
|
||||||
}
|
}
|
||||||
@@ -244,7 +245,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
|||||||
fromNodeNum := uint32(bot.NodeNum)
|
fromNodeNum := uint32(bot.NodeNum)
|
||||||
|
|
||||||
// direct 私聊走 PKI;channel 群聊保留旧的 AES-CTR + PSK 路径
|
// direct 私聊走 PKI;channel 群聊保留旧的 AES-CTR + PSK 路径
|
||||||
if messageType == botMessageTypeDirect {
|
if messageType == storepkg.BotMessageTypeDirect {
|
||||||
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy)
|
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +258,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
|||||||
}
|
}
|
||||||
psk := strings.TrimSpace(bot.PSK)
|
psk := strings.TrimSpace(bot.PSK)
|
||||||
if psk == "" {
|
if psk == "" {
|
||||||
psk = botDefaultPSK
|
psk = storepkg.BotDefaultPSK
|
||||||
}
|
}
|
||||||
key, err := mqtpp.ExpandPSK(psk)
|
key, err := mqtpp.ExpandPSK(psk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,7 +281,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
|
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
|
||||||
row := &botMessageRecord{
|
row := &storepkg.BotMessageRecord{
|
||||||
BotID: bot.ID,
|
BotID: bot.ID,
|
||||||
BotNodeID: bot.NodeID,
|
BotNodeID: bot.NodeID,
|
||||||
BotNodeNum: bot.NodeNum,
|
BotNodeNum: bot.NodeNum,
|
||||||
@@ -293,7 +294,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
|||||||
Text: text,
|
Text: text,
|
||||||
PayloadLen: int64(len(raw)),
|
PayloadLen: int64(len(raw)),
|
||||||
Encrypted: true,
|
Encrypted: true,
|
||||||
Status: botMessageStatusPending,
|
Status: storepkg.BotMessageStatusPending,
|
||||||
CreatedBy: strings.TrimSpace(req.CreatedBy),
|
CreatedBy: strings.TrimSpace(req.CreatedBy),
|
||||||
}
|
}
|
||||||
return s.persistAndPublish(row, topic, raw)
|
return s.persistAndPublish(row, topic, raw)
|
||||||
@@ -303,7 +304,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
|||||||
// - 从 nodeinfo 中查目标节点的 X25519 公钥
|
// - 从 nodeinfo 中查目标节点的 X25519 公钥
|
||||||
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
|
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
|
||||||
// - ServiceEnvelope.channel_id = "PKI",topic 也用 "PKI"
|
// - ServiceEnvelope.channel_id = "PKI",topic 也用 "PKI"
|
||||||
func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*botMessageRecord, error) {
|
func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*storepkg.BotMessageRecord, error) {
|
||||||
if toNodeID == nil {
|
if toNodeID == nil {
|
||||||
return nil, fmt.Errorf("target node id is required for pki direct message")
|
return nil, fmt.Errorf("target node id is required for pki direct message")
|
||||||
}
|
}
|
||||||
@@ -315,7 +316,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid bot private key: %w", err)
|
return nil, fmt.Errorf("invalid bot private key: %w", err)
|
||||||
}
|
}
|
||||||
senderPublic, err := decodeBotPublicKey(*bot)
|
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -339,11 +340,11 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
|
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
|
||||||
row := &botMessageRecord{
|
row := &storepkg.BotMessageRecord{
|
||||||
BotID: bot.ID,
|
BotID: bot.ID,
|
||||||
BotNodeID: bot.NodeID,
|
BotNodeID: bot.NodeID,
|
||||||
BotNodeNum: bot.NodeNum,
|
BotNodeNum: bot.NodeNum,
|
||||||
MessageType: botMessageTypeDirect,
|
MessageType: storepkg.BotMessageTypeDirect,
|
||||||
ChannelID: mqtpp.PKIChannelID,
|
ChannelID: mqtpp.PKIChannelID,
|
||||||
ToNodeID: toNodeID,
|
ToNodeID: toNodeID,
|
||||||
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
|
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
|
||||||
@@ -352,7 +353,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
|||||||
Text: text,
|
Text: text,
|
||||||
PayloadLen: int64(len(raw)),
|
PayloadLen: int64(len(raw)),
|
||||||
Encrypted: true,
|
Encrypted: true,
|
||||||
Status: botMessageStatusPending,
|
Status: storepkg.BotMessageStatusPending,
|
||||||
CreatedBy: strings.TrimSpace(createdBy),
|
CreatedBy: strings.TrimSpace(createdBy),
|
||||||
}
|
}
|
||||||
result, err := s.persistAndPublish(row, topic, raw)
|
result, err := s.persistAndPublish(row, topic, raw)
|
||||||
@@ -364,16 +365,16 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。
|
// recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。
|
||||||
func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
|
func (s *Service) recordOutboundDirectMessage(bot *storepkg.BotNodeRecord, msg *storepkg.BotMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
|
||||||
if s == nil || s.store == nil || msg == nil || bot == nil {
|
if s == nil || s.store == nil || msg == nil || bot == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := msg.Status
|
status := msg.Status
|
||||||
if status == "" {
|
if status == "" {
|
||||||
if sendErr != nil {
|
if sendErr != nil {
|
||||||
status = botMessageStatusFailed
|
status = storepkg.BotMessageStatusFailed
|
||||||
} else {
|
} else {
|
||||||
status = botMessageStatusPublished
|
status = storepkg.BotMessageStatusPublished
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
errText := msg.Error
|
errText := msg.Error
|
||||||
@@ -396,13 +397,13 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
|||||||
botMessageID = &id
|
botMessageID = &id
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
dm := &botDirectMessageRecord{
|
dm := &storepkg.BotDirectMessageRecord{
|
||||||
BotID: bot.ID,
|
BotID: bot.ID,
|
||||||
BotNodeID: bot.NodeID,
|
BotNodeID: bot.NodeID,
|
||||||
BotNodeNum: bot.NodeNum,
|
BotNodeNum: bot.NodeNum,
|
||||||
PeerNodeID: peerNodeID,
|
PeerNodeID: peerNodeID,
|
||||||
PeerNodeNum: int64(peerNodeNum),
|
PeerNodeNum: int64(peerNodeNum),
|
||||||
Direction: botDirectMessageDirectionOutbound,
|
Direction: storepkg.BotDirectMessageDirectionOutbound,
|
||||||
Topic: msg.Topic,
|
Topic: msg.Topic,
|
||||||
PacketID: msg.PacketID,
|
PacketID: msg.PacketID,
|
||||||
Text: text,
|
Text: text,
|
||||||
@@ -430,7 +431,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。
|
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。
|
||||||
func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
func (s *Service) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||||
node, err := s.store.GetNodeInfo(nodeID)
|
node, err := s.store.GetNodeInfo(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -458,33 +459,33 @@ func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。
|
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。
|
||||||
func (s *botService) persistAndPublish(row *botMessageRecord, topic string, raw []byte) (*botMessageRecord, error) {
|
func (s *Service) persistAndPublish(row *storepkg.BotMessageRecord, topic string, raw []byte) (*storepkg.BotMessageRecord, error) {
|
||||||
if err := s.store.InsertBotMessage(row); err != nil {
|
if err := s.store.InsertBotMessage(row); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if s.server == nil {
|
if s.server == nil {
|
||||||
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil)
|
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil)
|
||||||
row.Status = botMessageStatusFailed
|
row.Status = storepkg.BotMessageStatusFailed
|
||||||
row.Error = "mqtt server is not configured"
|
row.Error = "mqtt server is not configured"
|
||||||
return row, fmt.Errorf("mqtt server is not configured")
|
return row, fmt.Errorf("mqtt server is not configured")
|
||||||
}
|
}
|
||||||
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
||||||
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil)
|
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, err.Error(), nil)
|
||||||
row.Status = botMessageStatusFailed
|
row.Status = storepkg.BotMessageStatusFailed
|
||||||
row.Error = err.Error()
|
row.Error = err.Error()
|
||||||
return row, err
|
return row, err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := s.store.UpdateBotMessageStatus(row.ID, botMessageStatusPublished, "", &now); err != nil {
|
if err := s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusPublished, "", &now); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row.Status = botMessageStatusPublished
|
row.Status = storepkg.BotMessageStatusPublished
|
||||||
row.Error = ""
|
row.Error = ""
|
||||||
row.PublishedAt = &now
|
row.PublishedAt = &now
|
||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
|
func (s *Service) runNodeInfoBroadcaster(ctx context.Context) {
|
||||||
ticker := time.NewTicker(time.Minute)
|
ticker := time.NewTicker(time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
s.broadcastDueNodeInfo(ctx)
|
s.broadcastDueNodeInfo(ctx)
|
||||||
@@ -498,8 +499,8 @@ func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
func (s *Service) broadcastDueNodeInfo(ctx context.Context) {
|
||||||
rows, err := s.store.ListBotNodes(listOptions{Limit: 500})
|
rows, err := s.store.ListBotNodes(storepkg.ListOptions{Limit: 500})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()})
|
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -514,7 +515,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
|
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = time.Duration(botDefaultNodeInfoBroadcastSeconds) * time.Second
|
interval = time.Duration(storepkg.BotDefaultNodeInfoBroadcastSeconds) * time.Second
|
||||||
}
|
}
|
||||||
if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
|
if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
|
||||||
continue
|
continue
|
||||||
@@ -525,7 +526,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) {
|
func (s *Service) PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error) {
|
||||||
if s == nil || s.store == nil {
|
if s == nil || s.store == nil {
|
||||||
return nil, fmt.Errorf("bot service is not configured")
|
return nil, fmt.Errorf("bot service is not configured")
|
||||||
}
|
}
|
||||||
@@ -546,7 +547,7 @@ func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNo
|
|||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error {
|
func (s *Service) PublishNodeInfo(_ context.Context, bot storepkg.BotNodeRecord) error {
|
||||||
if s == nil || s.server == nil {
|
if s == nil || s.server == nil {
|
||||||
return fmt.Errorf("mqtt server is not configured")
|
return fmt.Errorf("mqtt server is not configured")
|
||||||
}
|
}
|
||||||
@@ -559,7 +560,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
|||||||
}
|
}
|
||||||
psk := strings.TrimSpace(bot.PSK)
|
psk := strings.TrimSpace(bot.PSK)
|
||||||
if psk == "" {
|
if psk == "" {
|
||||||
psk = botDefaultPSK
|
psk = storepkg.BotDefaultPSK
|
||||||
}
|
}
|
||||||
key, err := mqtpp.ExpandPSK(psk)
|
key, err := mqtpp.ExpandPSK(psk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -569,7 +570,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
publicKey, err := decodeBotPublicKey(bot)
|
publicKey, err := storepkg.DecodeBotPublicKey(bot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -612,21 +613,21 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
|||||||
|
|
||||||
func normalizeBotMessageType(value string) (string, error) {
|
func normalizeBotMessageType(value string) (string, error) {
|
||||||
switch strings.TrimSpace(value) {
|
switch strings.TrimSpace(value) {
|
||||||
case "", botMessageTypeChannel:
|
case "", storepkg.BotMessageTypeChannel:
|
||||||
return botMessageTypeChannel, nil
|
return storepkg.BotMessageTypeChannel, nil
|
||||||
case botMessageTypeDirect:
|
case storepkg.BotMessageTypeDirect:
|
||||||
return botMessageTypeDirect, nil
|
return storepkg.BotMessageTypeDirect, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("message type must be channel or direct")
|
return "", fmt.Errorf("message type must be channel or direct")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) {
|
func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) {
|
||||||
if messageType == botMessageTypeChannel {
|
if messageType == storepkg.BotMessageTypeChannel {
|
||||||
return int64(mqtpp.NodeNumBroadcast), nil, nil
|
return int64(mqtpp.NodeNumBroadcast), nil, nil
|
||||||
}
|
}
|
||||||
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
|
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
|
||||||
if err := validateBotNodeNum(*req.ToNodeNum); err != nil {
|
if err := storepkg.ValidateBotNodeNum(*req.ToNodeNum); err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
|
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
|
||||||
@@ -640,7 +641,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
if err := validateBotNodeNum(int64(nodeNum)); err != nil {
|
if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
normalized := mqtpp.NodeNumToID(nodeNum)
|
normalized := mqtpp.NodeNumToID(nodeNum)
|
||||||
@@ -650,7 +651,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
|
|||||||
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
|
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
|
||||||
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
|
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
prefix = botDefaultTopicPrefix
|
prefix = storepkg.BotDefaultTopicPrefix
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(prefix, "/2/e") {
|
if strings.HasSuffix(prefix, "/2/e") {
|
||||||
return prefix + "/" + channelID + "/" + nodeID
|
return prefix + "/" + channelID + "/" + nodeID
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
// printJSON 是 bot service 的内部诊断输出钩子;当前为 noop,与重构前
|
||||||
|
// main.go 中的 printJSON 行为一致(注释掉了实际写出)。
|
||||||
|
// 如需调试可直接替换实现。
|
||||||
|
func printJSON(record map[string]any) {
|
||||||
|
_ = record
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
cryptotls "crypto/tls"
|
cryptotls "crypto/tls"
|
||||||
@@ -10,49 +10,54 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const configFileName = "config.yaml"
|
const FileName = "config.yaml"
|
||||||
|
|
||||||
type config struct {
|
const (
|
||||||
MQTT mqttConfig `yaml:"mqtt"`
|
DriverSQLite = "sqlite"
|
||||||
Meshtastic meshtasticConfig `yaml:"meshtastic"`
|
DriverMySQL = "mysql"
|
||||||
Database databaseConfig `yaml:"database"`
|
)
|
||||||
Web webConfig `yaml:"web"`
|
|
||||||
AI aiConfig `yaml:"ai"`
|
type Config struct {
|
||||||
|
MQTT MQTTConfig `yaml:"mqtt"`
|
||||||
|
Meshtastic MeshtasticConfig `yaml:"meshtastic"`
|
||||||
|
Database DatabaseConfig `yaml:"database"`
|
||||||
|
Web WebConfig `yaml:"web"`
|
||||||
|
AI AIConfig `yaml:"ai"`
|
||||||
DataDir string `yaml:"data_dir"`
|
DataDir string `yaml:"data_dir"`
|
||||||
key []byte
|
Key []byte `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type mqttConfig struct {
|
type MQTTConfig struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
TLS tlsConfig `yaml:"tls"`
|
TLS TLSConfig `yaml:"tls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tlsConfig struct {
|
type TLSConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
CertFile string `yaml:"cert_file"`
|
CertFile string `yaml:"cert_file"`
|
||||||
KeyFile string `yaml:"key_file"`
|
KeyFile string `yaml:"key_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type meshtasticConfig struct {
|
type MeshtasticConfig struct {
|
||||||
PSK string `yaml:"psk"`
|
PSK string `yaml:"psk"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type databaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
Driver string `yaml:"driver"`
|
Driver string `yaml:"driver"`
|
||||||
SQLite sqliteConfig `yaml:"sqlite"`
|
SQLite SQLiteConfig `yaml:"sqlite"`
|
||||||
MySQL mysqlConfig `yaml:"mysql"`
|
MySQL MySQLConfig `yaml:"mysql"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sqliteConfig struct {
|
type SQLiteConfig struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type mysqlConfig struct {
|
type MySQLConfig struct {
|
||||||
DSN string `yaml:"dsn"`
|
DSN string `yaml:"dsn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type webConfig struct {
|
type WebConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
PortEnabled bool `yaml:"port_enabled"`
|
PortEnabled bool `yaml:"port_enabled"`
|
||||||
SocketEnabled bool `yaml:"socket_enabled"`
|
SocketEnabled bool `yaml:"socket_enabled"`
|
||||||
@@ -61,17 +66,17 @@ type webConfig struct {
|
|||||||
SocketPath string `yaml:"socket_path"`
|
SocketPath string `yaml:"socket_path"`
|
||||||
StaticDir string `yaml:"static_dir"`
|
StaticDir string `yaml:"static_dir"`
|
||||||
MapTileCacheDir string `yaml:"map_tile_cache_dir"`
|
MapTileCacheDir string `yaml:"map_tile_cache_dir"`
|
||||||
Admin webAdminConfig `yaml:"admin"`
|
Admin WebAdminConfig `yaml:"admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type webAdminConfig struct {
|
type WebAdminConfig struct {
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
SessionSecret string `yaml:"session_secret"`
|
SessionSecret string `yaml:"session_secret"`
|
||||||
SessionSecure bool `yaml:"session_secure"`
|
SessionSecure bool `yaml:"session_secure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type aiConfig struct {
|
type AIConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,27 +142,27 @@ type rawWebAdminConfig struct {
|
|||||||
SessionSecure *bool `yaml:"session_secure"`
|
SessionSecure *bool `yaml:"session_secure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultConfig 返回内置默认配置。
|
// Default 返回内置默认配置。
|
||||||
func defaultConfig() *config {
|
func Default() *Config {
|
||||||
return &config{
|
return &Config{
|
||||||
MQTT: mqttConfig{
|
MQTT: MQTTConfig{
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
Port: 1883,
|
Port: 1883,
|
||||||
TLS: tlsConfig{
|
TLS: TLSConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
CertFile: "",
|
CertFile: "",
|
||||||
KeyFile: "",
|
KeyFile: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Meshtastic: meshtasticConfig{
|
Meshtastic: MeshtasticConfig{
|
||||||
PSK: "AQ==",
|
PSK: "AQ==",
|
||||||
},
|
},
|
||||||
Database: databaseConfig{
|
Database: DatabaseConfig{
|
||||||
Driver: "sqlite",
|
Driver: DriverSQLite,
|
||||||
SQLite: sqliteConfig{Path: defaultSQLitePath()},
|
SQLite: SQLiteConfig{Path: defaultSQLitePath()},
|
||||||
MySQL: mysqlConfig{DSN: ""},
|
MySQL: MySQLConfig{DSN: ""},
|
||||||
},
|
},
|
||||||
Web: webConfig{
|
Web: WebConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
PortEnabled: true,
|
PortEnabled: true,
|
||||||
SocketEnabled: defaultWebSocketPath() != "",
|
SocketEnabled: defaultWebSocketPath() != "",
|
||||||
@@ -166,22 +171,22 @@ func defaultConfig() *config {
|
|||||||
SocketPath: defaultWebSocketPath(),
|
SocketPath: defaultWebSocketPath(),
|
||||||
StaticDir: "./dist",
|
StaticDir: "./dist",
|
||||||
MapTileCacheDir: defaultMapTileCacheDir(),
|
MapTileCacheDir: defaultMapTileCacheDir(),
|
||||||
Admin: webAdminConfig{
|
Admin: WebAdminConfig{
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: "admin",
|
Password: "admin",
|
||||||
SessionSecret: "",
|
SessionSecret: "",
|
||||||
SessionSecure: false,
|
SessionSecure: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AI: aiConfig{
|
AI: AIConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
DataDir: defaultDataDir(),
|
DataDir: defaultDataDir(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultConfigDir 根据操作系统返回配置目录。
|
// DefaultDir 根据操作系统返回配置目录。
|
||||||
func defaultConfigDir() string {
|
func DefaultDir() string {
|
||||||
return defaultConfigDirForGOOS(runtime.GOOS)
|
return defaultConfigDirForGOOS(runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +201,9 @@ func useRelativeDefaultPath(goos string) bool {
|
|||||||
return goos == "windows" || goos == "darwin"
|
return goos == "windows" || goos == "darwin"
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultConfigPath 返回默认配置文件路径。
|
// DefaultPath 返回默认配置文件路径。
|
||||||
func defaultConfigPath() string {
|
func DefaultPath() string {
|
||||||
return filepath.Join(defaultConfigDir(), configFileName)
|
return filepath.Join(DefaultDir(), FileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultSQLitePath() string {
|
func defaultSQLitePath() string {
|
||||||
@@ -230,7 +235,7 @@ func defaultWebSocketPathForGOOS(goos string) string {
|
|||||||
return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
|
return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearWebSocketPathOnUnsupportedGOOS(cfg *config, goos string) bool {
|
func ClearWebSocketPathOnUnsupportedGOOS(cfg *Config, goos string) bool {
|
||||||
if goos != "windows" {
|
if goos != "windows" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -264,10 +269,10 @@ func defaultDataDirForGOOS(goos string) string {
|
|||||||
return filepath.Join(string(filepath.Separator), "var", "lib", "mesh_mqtt_go")
|
return filepath.Join(string(filepath.Separator), "var", "lib", "mesh_mqtt_go")
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig 加载配置文件;文件不存在时生成,字段缺失时自动补全并写回。
|
// Load 加载配置文件;文件不存在时生成,字段缺失时自动补全并写回。
|
||||||
func loadConfig(path string) (*config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = defaultConfigPath()
|
path = DefaultPath()
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
return nil, fmt.Errorf("create config directory %s: %w", filepath.Dir(path), err)
|
return nil, fmt.Errorf("create config directory %s: %w", filepath.Dir(path), err)
|
||||||
@@ -277,8 +282,8 @@ func loadConfig(path string) (*config, error) {
|
|||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("stat config file %s: %w", path, err)
|
return nil, fmt.Errorf("stat config file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
cfg := defaultConfig()
|
cfg := Default()
|
||||||
if err := writeConfig(path, cfg); err != nil {
|
if err := Write(path, cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -294,24 +299,24 @@ func loadConfig(path string) (*config, error) {
|
|||||||
return nil, fmt.Errorf("parse config file %s: %w", path, err)
|
return nil, fmt.Errorf("parse config file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, changed := normalizeConfig(raw)
|
cfg, changed := normalize(raw)
|
||||||
if clearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) {
|
if ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if err := validateConfig(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
if err := writeConfig(path, cfg); err != nil {
|
if err := Write(path, cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeConfig 将原始配置合并到默认配置,并标记是否补齐了缺失项。
|
// normalize 将原始配置合并到默认配置,并标记是否补齐了缺失项。
|
||||||
func normalizeConfig(raw rawConfig) (*config, bool) {
|
func normalize(raw rawConfig) (*Config, bool) {
|
||||||
cfg := defaultConfig()
|
cfg := Default()
|
||||||
changed := false
|
changed := false
|
||||||
|
|
||||||
if raw.MQTT == nil {
|
if raw.MQTT == nil {
|
||||||
@@ -468,16 +473,16 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
|
|||||||
return cfg, changed
|
return cfg, changed
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateConfig(cfg *config) error {
|
func Validate(cfg *Config) error {
|
||||||
if cfg.MQTT.Port <= 0 || cfg.MQTT.Port > 65535 {
|
if cfg.MQTT.Port <= 0 || cfg.MQTT.Port > 65535 {
|
||||||
return fmt.Errorf("invalid mqtt port %d: must be 1-65535", cfg.MQTT.Port)
|
return fmt.Errorf("invalid mqtt port %d: must be 1-65535", cfg.MQTT.Port)
|
||||||
}
|
}
|
||||||
switch cfg.Database.Driver {
|
switch cfg.Database.Driver {
|
||||||
case "sqlite":
|
case DriverSQLite:
|
||||||
if cfg.Database.SQLite.Path == "" {
|
if cfg.Database.SQLite.Path == "" {
|
||||||
return fmt.Errorf("database.sqlite.path is required when database.driver is sqlite")
|
return fmt.Errorf("database.sqlite.path is required when database.driver is sqlite")
|
||||||
}
|
}
|
||||||
case "mysql":
|
case DriverMySQL:
|
||||||
if cfg.Database.MySQL.DSN == "" {
|
if cfg.Database.MySQL.DSN == "" {
|
||||||
return fmt.Errorf("database.mysql.dsn is required when database.driver is mysql")
|
return fmt.Errorf("database.mysql.dsn is required when database.driver is mysql")
|
||||||
}
|
}
|
||||||
@@ -510,7 +515,7 @@ func validateConfig(cfg *config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeConfig(path string, cfg *config) error {
|
func Write(path string, cfg *Config) error {
|
||||||
data, err := yaml.Marshal(cfg)
|
data, err := yaml.Marshal(cfg)
|
||||||
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)
|
||||||
@@ -521,8 +526,8 @@ func writeConfig(path string, cfg *config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTLSConfig 根据配置构造 mochi listener 使用的 TLS 设置。
|
// BuildTLS 根据配置构造 mochi listener 使用的 TLS 设置。
|
||||||
func buildTLSConfig(cfg tlsConfig) (*cryptotls.Config, error) {
|
func BuildTLS(cfg TLSConfig) (*cryptotls.Config, error) {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadConfigCreatesDefaultFile(t *testing.T) {
|
func TestLoadConfigCreatesDefaultFile(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName)
|
||||||
|
|
||||||
cfg, err := loadConfig(path)
|
cfg, err := Load(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadConfig() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
if cfg.MQTT.Host != "0.0.0.0" {
|
if cfg.MQTT.Host != "0.0.0.0" {
|
||||||
t.Fatalf("host = %q, want 0.0.0.0", cfg.MQTT.Host)
|
t.Fatalf("host = %q, want 0.0.0.0", cfg.MQTT.Host)
|
||||||
@@ -60,7 +60,7 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfigFillsMissingFields(t *testing.T) {
|
func TestLoadConfigFillsMissingFields(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName)
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -68,9 +68,9 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig(path)
|
cfg, err := Load(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadConfig() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
if cfg.MQTT.Port != 1884 {
|
if cfg.MQTT.Port != 1884 {
|
||||||
t.Fatalf("port = %d, want 1884", cfg.MQTT.Port)
|
t.Fatalf("port = %d, want 1884", cfg.MQTT.Port)
|
||||||
@@ -98,7 +98,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfigPreservesExplicitFalse(t *testing.T) {
|
func TestLoadConfigPreservesExplicitFalse(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName)
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -107,9 +107,9 @@ func TestLoadConfigPreservesExplicitFalse(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig(path)
|
cfg, err := Load(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadConfig() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
if cfg.MQTT.TLS.Enabled {
|
if cfg.MQTT.TLS.Enabled {
|
||||||
t.Fatalf("tls enabled = true, want explicit false")
|
t.Fatalf("tls enabled = true, want explicit false")
|
||||||
@@ -120,7 +120,7 @@ func TestLoadConfigPreservesExplicitFalse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
|
func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName)
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -129,9 +129,9 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig(path)
|
cfg, err := Load(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadConfig() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
if cfg.Web.Enabled {
|
if cfg.Web.Enabled {
|
||||||
t.Fatalf("web enabled = true, want explicit false")
|
t.Fatalf("web enabled = true, want explicit false")
|
||||||
@@ -145,7 +145,7 @@ func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
|
func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", FileName)
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -154,9 +154,9 @@ func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := loadConfig(path)
|
_, err := Load(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("loadConfig() error = nil, want parse error")
|
t.Fatalf("Load() error = nil, want parse error")
|
||||||
}
|
}
|
||||||
data, readErr := os.ReadFile(path)
|
data, readErr := os.ReadFile(path)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
@@ -218,10 +218,10 @@ func TestDefaultWebSocketPathForGOOS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
|
func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
|
||||||
cfg := defaultConfig()
|
cfg := Default()
|
||||||
cfg.Web.SocketPath = filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
|
cfg.Web.SocketPath = filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
|
||||||
if !clearWebSocketPathOnUnsupportedGOOS(cfg, "windows") {
|
if !ClearWebSocketPathOnUnsupportedGOOS(cfg, "windows") {
|
||||||
t.Fatalf("clearWebSocketPathOnUnsupportedGOOS() = false, want true")
|
t.Fatalf("ClearWebSocketPathOnUnsupportedGOOS() = false, want true")
|
||||||
}
|
}
|
||||||
if cfg.Web.SocketPath != "" {
|
if cfg.Web.SocketPath != "" {
|
||||||
t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath)
|
t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath)
|
||||||
@@ -231,8 +231,8 @@ func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock"
|
cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock"
|
||||||
if clearWebSocketPathOnUnsupportedGOOS(cfg, "linux") {
|
if ClearWebSocketPathOnUnsupportedGOOS(cfg, "linux") {
|
||||||
t.Fatalf("linux clearWebSocketPathOnUnsupportedGOOS() = true, want false")
|
t.Fatalf("linux ClearWebSocketPathOnUnsupportedGOOS() = true, want false")
|
||||||
}
|
}
|
||||||
if cfg.Web.SocketPath == "" {
|
if cfg.Web.SocketPath == "" {
|
||||||
t.Fatalf("linux web socket path was cleared")
|
t.Fatalf("linux web socket path was cleared")
|
||||||
@@ -256,102 +256,102 @@ func TestDefaultSQLitePathForGOOS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateConfigDatabase(t *testing.T) {
|
func TestValidateConfigDatabase(t *testing.T) {
|
||||||
cfg := defaultConfig()
|
cfg := Default()
|
||||||
cfg.Database.Driver = "postgres"
|
cfg.Database.Driver = "postgres"
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.driver") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.driver") {
|
||||||
t.Fatalf("invalid driver error = %v, want database.driver error", err)
|
t.Fatalf("invalid driver error = %v, want database.driver error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Database.SQLite.Path = ""
|
cfg.Database.SQLite.Path = ""
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.sqlite.path") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.sqlite.path") {
|
||||||
t.Fatalf("missing sqlite path error = %v, want database.sqlite.path error", err)
|
t.Fatalf("missing sqlite path error = %v, want database.sqlite.path error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Database.Driver = "mysql"
|
cfg.Database.Driver = "mysql"
|
||||||
cfg.Database.MySQL.DSN = ""
|
cfg.Database.MySQL.DSN = ""
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.mysql.dsn") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "database.mysql.dsn") {
|
||||||
t.Fatalf("missing mysql dsn error = %v, want database.mysql.dsn error", err)
|
t.Fatalf("missing mysql dsn error = %v, want database.mysql.dsn error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateConfigWeb(t *testing.T) {
|
func TestValidateConfigWeb(t *testing.T) {
|
||||||
cfg := defaultConfig()
|
cfg := Default()
|
||||||
cfg.Web.Port = 0
|
cfg.Web.Port = 0
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web port") {
|
||||||
t.Fatalf("invalid web port error = %v, want web port error", err)
|
t.Fatalf("invalid web port error = %v, want web port error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Web.PortEnabled = false
|
cfg.Web.PortEnabled = false
|
||||||
cfg.Web.Port = 0
|
cfg.Web.Port = 0
|
||||||
if err := validateConfig(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
t.Fatalf("disabled web port with invalid port error = %v, want nil", err)
|
t.Fatalf("disabled web port with invalid port error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Web.SocketEnabled = false
|
cfg.Web.SocketEnabled = false
|
||||||
cfg.Web.SocketPath = ""
|
cfg.Web.SocketPath = ""
|
||||||
if err := validateConfig(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
t.Fatalf("disabled web socket with empty path error = %v, want nil", err)
|
t.Fatalf("disabled web socket with empty path error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Web.PortEnabled = false
|
cfg.Web.PortEnabled = false
|
||||||
cfg.Web.SocketEnabled = true
|
cfg.Web.SocketEnabled = true
|
||||||
cfg.Web.SocketPath = ""
|
cfg.Web.SocketPath = ""
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.socket_path") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.socket_path") {
|
||||||
t.Fatalf("missing web socket path error = %v, want web.socket_path error", err)
|
t.Fatalf("missing web socket path error = %v, want web.socket_path error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Web.PortEnabled = false
|
cfg.Web.PortEnabled = false
|
||||||
cfg.Web.SocketEnabled = false
|
cfg.Web.SocketEnabled = false
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.port_enabled") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.port_enabled") {
|
||||||
t.Fatalf("disabled web listeners error = %v, want web.port_enabled error", err)
|
t.Fatalf("disabled web listeners error = %v, want web.port_enabled error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Web.StaticDir = ""
|
cfg.Web.StaticDir = ""
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.static_dir") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.static_dir") {
|
||||||
t.Fatalf("missing web static dir error = %v, want web.static_dir error", err)
|
t.Fatalf("missing web static dir error = %v, want web.static_dir error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Web.MapTileCacheDir = ""
|
cfg.Web.MapTileCacheDir = ""
|
||||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") {
|
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") {
|
||||||
t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err)
|
t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg = defaultConfig()
|
cfg = Default()
|
||||||
cfg.Web.Enabled = false
|
cfg.Web.Enabled = false
|
||||||
cfg.Web.PortEnabled = false
|
cfg.Web.PortEnabled = false
|
||||||
cfg.Web.SocketEnabled = false
|
cfg.Web.SocketEnabled = false
|
||||||
cfg.Web.Port = 0
|
cfg.Web.Port = 0
|
||||||
cfg.Web.StaticDir = ""
|
cfg.Web.StaticDir = ""
|
||||||
if err := validateConfig(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
t.Fatalf("disabled web validate error = %v, want nil", err)
|
t.Fatalf("disabled web validate error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildTLSConfigDisabled(t *testing.T) {
|
func TestBuildTLSConfigDisabled(t *testing.T) {
|
||||||
cfg, err := buildTLSConfig(tlsConfig{})
|
cfg, err := BuildTLS(TLSConfig{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildTLSConfig() error = %v", err)
|
t.Fatalf("BuildTLS() error = %v", err)
|
||||||
}
|
}
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
t.Fatalf("buildTLSConfig() = %#v, want nil", cfg)
|
t.Fatalf("BuildTLS() = %#v, want nil", cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildTLSConfigRequiresCertAndKey(t *testing.T) {
|
func TestBuildTLSConfigRequiresCertAndKey(t *testing.T) {
|
||||||
_, err := buildTLSConfig(tlsConfig{Enabled: true})
|
_, err := BuildTLS(TLSConfig{Enabled: true})
|
||||||
if err == nil || !strings.Contains(err.Error(), "cert_file") {
|
if err == nil || !strings.Contains(err.Error(), "cert_file") {
|
||||||
t.Fatalf("missing cert error = %v, want cert_file error", err)
|
t.Fatalf("missing cert error = %v, want cert_file error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = buildTLSConfig(tlsConfig{Enabled: true, CertFile: "cert.pem"})
|
_, err = BuildTLS(TLSConfig{Enabled: true, CertFile: "cert.pem"})
|
||||||
if err == nil || !strings.Contains(err.Error(), "key_file") {
|
if err == nil || !strings.Contains(err.Error(), "key_file") {
|
||||||
t.Fatalf("missing key error = %v, want key_file error", err)
|
t.Fatalf("missing key error = %v, want key_file error", err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package help
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -7,13 +7,17 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/auth"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type helpContentRequest struct {
|
type helpContentRequest struct {
|
||||||
Markdown string `json:"markdown"`
|
Markdown string `json:"markdown"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerHelpRoutes(r gin.IRouter, store *store) {
|
// RegisterPublicRoutes 把对外可见的 GET /help 挂到给定路由组下。
|
||||||
|
func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||||
r.GET("/help", func(c *gin.Context) {
|
r.GET("/help", func(c *gin.Context) {
|
||||||
item, err := latestHelpContentDTO(store)
|
item, err := latestHelpContentDTO(store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -24,7 +28,8 @@ func registerHelpRoutes(r gin.IRouter, store *store) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
// RegisterAdminRoutes 注册管理员侧 /help、/help、/help/preview 这三条路由。
|
||||||
|
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||||
r.GET("/help", func(c *gin.Context) {
|
r.GET("/help", func(c *gin.Context) {
|
||||||
item, err := latestHelpContentDTO(store)
|
item, err := latestHelpContentDTO(store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,7 +44,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
claims := c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)
|
||||||
row, err := store.InsertHelpContent(req.Markdown, claims.Username)
|
row, err := store.InsertHelpContent(req.Markdown, claims.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -58,7 +63,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
html, err := renderHelpMarkdown(req.Markdown)
|
html, err := RenderMarkdown(req.Markdown)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -67,10 +72,10 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func latestHelpContentDTO(store *store) (gin.H, error) {
|
func latestHelpContentDTO(store *storepkg.Store) (gin.H, error) {
|
||||||
row, err := store.GetLatestHelpContent()
|
row, err := store.GetLatestHelpContent()
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return helpContentDTO(0, defaultHelpMarkdown, "", nil)
|
return helpContentDTO(0, storepkg.DefaultHelpMarkdown, "", nil)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -79,7 +84,7 @@ func latestHelpContentDTO(store *store) (gin.H, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) {
|
func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) {
|
||||||
html, err := renderHelpMarkdown(markdown)
|
html, err := RenderMarkdown(markdown)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package help
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderHelpMarkdown(markdown string) (string, error) {
|
// RenderMarkdown 把 GFM markdown 转成净化后的 HTML,供 admin 编辑器预览
|
||||||
|
// 与 /help 路由直接渲染。
|
||||||
|
func RenderMarkdown(markdown string) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
|
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
|
||||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package llmadmin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -8,9 +8,12 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
|
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
|
||||||
group := r.Group("/llm")
|
group := r.Group("/llm")
|
||||||
{
|
{
|
||||||
// LLM Message Queue
|
// LLM Message Queue
|
||||||
@@ -38,9 +41,9 @@ func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleListLLMMessages(store *store) gin.HandlerFunc {
|
func handleListLLMMessages(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,7 +68,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc {
|
|||||||
|
|
||||||
items := make([]map[string]any, 0, len(rows))
|
items := make([]map[string]any, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, llmMessageDTO(row))
|
items = append(items, storepkg.LLMMessageDTO(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -77,7 +80,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetLLMMessage(store *store) gin.HandlerFunc {
|
func handleGetLLMMessage(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -95,11 +98,11 @@ func handleGetLLMMessage(store *store) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"item": llmMessageDTO(*record)})
|
c.JSON(http.StatusOK, gin.H{"item": storepkg.LLMMessageDTO(*record)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
|
func handleUpdateLLMMessageStatus(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -118,10 +121,10 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
|
|||||||
|
|
||||||
// 验证状态值
|
// 验证状态值
|
||||||
validStatus := map[string]bool{
|
validStatus := map[string]bool{
|
||||||
llmMessageStatusPending: true,
|
storepkg.LLMMessageStatusPending: true,
|
||||||
llmMessageStatusProcessing: true,
|
storepkg.LLMMessageStatusProcessing: true,
|
||||||
llmMessageStatusProcessed: true,
|
storepkg.LLMMessageStatusProcessed: true,
|
||||||
llmMessageStatusError: true,
|
storepkg.LLMMessageStatusError: true,
|
||||||
}
|
}
|
||||||
if !validStatus[req.Status] {
|
if !validStatus[req.Status] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"})
|
||||||
@@ -141,7 +144,7 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDeleteLLMMessage(store *store) gin.HandlerFunc {
|
func handleDeleteLLMMessage(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -162,7 +165,7 @@ func handleDeleteLLMMessage(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc {
|
func handleDeleteLLMMessagesByBot(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64)
|
botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64)
|
||||||
if err != nil || botID == 0 {
|
if err != nil || botID == 0 {
|
||||||
@@ -179,7 +182,7 @@ func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc {
|
func handleCleanupDeletedLLMMessages(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Days int `json:"days"`
|
Days int `json:"days"`
|
||||||
@@ -206,7 +209,7 @@ func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc {
|
|||||||
// LLM Provider Handlers
|
// LLM Provider Handlers
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
func handleListLLMProviders(store *store) gin.HandlerFunc {
|
func handleListLLMProviders(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
includeInactive := c.Query("include_inactive") == "true"
|
includeInactive := c.Query("include_inactive") == "true"
|
||||||
|
|
||||||
@@ -225,7 +228,7 @@ func handleListLLMProviders(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetLLMProvider(store *store) gin.HandlerFunc {
|
func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -247,7 +250,7 @@ func handleGetLLMProvider(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateLLMProvider(store *store) gin.HandlerFunc {
|
func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -268,7 +271,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
record := &llmProviderRecord{
|
record := &storepkg.LLMProviderRecord{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Active: req.Active,
|
Active: req.Active,
|
||||||
APIKey: req.APIKey,
|
APIKey: req.APIKey,
|
||||||
@@ -287,7 +290,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateLLMProvider(store *store) gin.HandlerFunc {
|
func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -352,7 +355,7 @@ func handleUpdateLLMProvider(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDeleteLLMProvider(store *store) gin.HandlerFunc {
|
func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -369,7 +372,7 @@ func handleDeleteLLMProvider(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func llmProviderDTO(row llmProviderRecord) map[string]any {
|
func llmProviderDTO(row storepkg.LLMProviderRecord) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"name": row.Name,
|
"name": row.Name,
|
||||||
"active": row.Active,
|
"active": row.Active,
|
||||||
@@ -386,7 +389,7 @@ func llmProviderDTO(row llmProviderRecord) map[string]any {
|
|||||||
// LLM Tool Router Handlers
|
// LLM Tool Router Handlers
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
func handleGetLLMToolRouter(store *store) gin.HandlerFunc {
|
func handleGetLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
record, err := store.GetLLMToolRouter()
|
record, err := store.GetLLMToolRouter()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -402,7 +405,7 @@ func handleGetLLMToolRouter(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc {
|
func handleUpdateLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
record, err := store.GetLLMToolRouter()
|
record, err := store.GetLLMToolRouter()
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -446,7 +449,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc {
|
|||||||
|
|
||||||
if record == nil {
|
if record == nil {
|
||||||
// 创建新配置
|
// 创建新配置
|
||||||
newRecord := &llmToolRouterRecord{
|
newRecord := &storepkg.LLMToolRouterRecord{
|
||||||
Enabled: req.Enabled != nil && *req.Enabled,
|
Enabled: req.Enabled != nil && *req.Enabled,
|
||||||
OpenAIName: "",
|
OpenAIName: "",
|
||||||
Timeout: 30,
|
Timeout: 30,
|
||||||
@@ -487,7 +490,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func llmToolRouterDTO(row llmToolRouterRecord) map[string]any {
|
func llmToolRouterDTO(row storepkg.LLMToolRouterRecord) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"enabled": row.Enabled,
|
"enabled": row.Enabled,
|
||||||
@@ -504,7 +507,7 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any {
|
|||||||
// LLM Primary Config Handlers
|
// LLM Primary Config Handlers
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc {
|
func handleGetLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
record, err := store.GetLLMPrimaryConfig()
|
record, err := store.GetLLMPrimaryConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -520,7 +523,7 @@ func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
|
func handleUpdateLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
record, err := store.GetLLMPrimaryConfig()
|
record, err := store.GetLLMPrimaryConfig()
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -568,7 +571,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
|
|||||||
|
|
||||||
if record == nil {
|
if record == nil {
|
||||||
// 创建新配置
|
// 创建新配置
|
||||||
newRecord := &llmPrimaryConfigRecord{
|
newRecord := &storepkg.LLMPrimaryConfigRecord{
|
||||||
Enabled: req.Enabled != nil && *req.Enabled,
|
Enabled: req.Enabled != nil && *req.Enabled,
|
||||||
ProviderName: "",
|
ProviderName: "",
|
||||||
Timeout: 120,
|
Timeout: 120,
|
||||||
@@ -613,7 +616,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func llmPrimaryConfigDTO(row llmPrimaryConfigRecord) map[string]any {
|
func llmPrimaryConfigDTO(row storepkg.LLMPrimaryConfigRecord) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"enabled": row.Enabled,
|
"enabled": row.Enabled,
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package main
|
// Package mapsource 提供地图瓦片源的 admin 与公开路由。
|
||||||
|
package mapsource
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -7,6 +8,9 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mapTileSourceRequest struct {
|
type mapTileSourceRequest struct {
|
||||||
@@ -19,14 +23,15 @@ type mapTileSourceRequest struct {
|
|||||||
ProxyEnabled bool `json:"proxy_enabled"`
|
ProxyEnabled bool `json:"proxy_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerMapSourceRoutes(r gin.IRouter, store *store) {
|
// RegisterPublicRoutes 把对外可见的 GET /map-source/{default,enabled} 挂上去。
|
||||||
|
func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||||
r.GET("/map-source/default", func(c *gin.Context) {
|
r.GET("/map-source/default", func(c *gin.Context) {
|
||||||
row, err := store.GetDefaultMapTileSource()
|
row, err := store.GetDefaultMapTileSource()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
|
c.JSON(http.StatusOK, gin.H{"item": PublicDTO(*row)})
|
||||||
})
|
})
|
||||||
r.GET("/map-source/enabled", func(c *gin.Context) {
|
r.GET("/map-source/enabled", func(c *gin.Context) {
|
||||||
rows, err := store.ListEnabledMapTileSources()
|
rows, err := store.ListEnabledMapTileSources()
|
||||||
@@ -36,25 +41,26 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) {
|
|||||||
}
|
}
|
||||||
items := make([]gin.H, 0, len(rows))
|
items := make([]gin.H, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, publicMapTileSourceDTO(row))
|
items = append(items, PublicDTO(row))
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
|
// RegisterAdminRoutes 注册管理员侧 CRUD 与设默认。
|
||||||
|
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||||
r.GET("/map-source", func(c *gin.Context) {
|
r.GET("/map-source", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListMapTileSources(opts)
|
rows, err := store.ListMapTileSources(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, mapTileSourceDTO)
|
webutil.WriteListResponse(c, rows, opts, err, AdminDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountMapTileSources(opts)
|
total, err := store.CountMapTileSources(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, AdminDTO)
|
||||||
})
|
})
|
||||||
r.POST("/map-source", func(c *gin.Context) {
|
r.POST("/map-source", func(c *gin.Context) {
|
||||||
var req mapTileSourceRequest
|
var req mapTileSourceRequest
|
||||||
@@ -95,8 +101,8 @@ func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput {
|
func mapTileSourceInputFromRequest(req mapTileSourceRequest) storepkg.MapTileSourceInput {
|
||||||
return mapTileSourceInput{
|
return storepkg.MapTileSourceInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
URLTemplate: req.URLTemplate,
|
URLTemplate: req.URLTemplate,
|
||||||
Attribution: req.Attribution,
|
Attribution: req.Attribution,
|
||||||
@@ -116,8 +122,8 @@ func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
|
|||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) {
|
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *storepkg.MapTileSourceRecord, err error) {
|
||||||
if errors.Is(err, errMapTileSourceAlreadyExists) {
|
if errors.Is(err, storepkg.ErrMapTileSourceAlreadyExists) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -125,7 +131,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, errMapTileSourceCannotDeleteDefault) || errors.Is(err, errMapTileSourceCannotDisableDefault) || errors.Is(err, errMapTileSourceDefaultMustBeEnabled) {
|
if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) || errors.Is(err, storepkg.ErrMapTileSourceCannotDisableDefault) || errors.Is(err, storepkg.ErrMapTileSourceDefaultMustBeEnabled) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -133,7 +139,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)})
|
c.JSON(status, gin.H{"item": AdminDTO(*row)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
||||||
@@ -141,7 +147,7 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, errMapTileSourceCannotDeleteDefault) {
|
if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -152,16 +158,19 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapTileSourceDTO(row mapTileSourceRecord) gin.H {
|
// AdminDTO 是管理后台展示的全字段视图。
|
||||||
|
func AdminDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H {
|
// PublicDTO 是给前端用户使用的视图:当 ProxyEnabled 为 true 时,url 改写为
|
||||||
|
// 通过本服务的 /api/map/{hash} 代理路径,避免暴露上游瓦片地址。
|
||||||
|
func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||||
urlTemplate := row.URLTemplate
|
urlTemplate := row.URLTemplate
|
||||||
if row.ProxyEnabled {
|
if row.ProxyEnabled {
|
||||||
hash := row.URLTemplateHash
|
hash := row.URLTemplateHash
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
hash = mapTileSourceHash(row.URLTemplate)
|
hash = storepkg.MapTileSourceHash(row.URLTemplate)
|
||||||
}
|
}
|
||||||
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
|
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package mqttforward
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mqttForwarderRequest struct {
|
type mqttForwarderRequest struct {
|
||||||
@@ -38,19 +41,19 @@ type mqttForwardTopicRequest struct {
|
|||||||
Retain bool `json:"retain"`
|
Retain bool `json:"retain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttForwardReloader) {
|
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, forwarder Reloader) {
|
||||||
r.GET("/mqtt-forward/forwarders", func(c *gin.Context) {
|
r.GET("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListMQTTForwarders(opts)
|
rows, err := store.ListMQTTForwarders(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, mqttForwarderDTO)
|
webutil.WriteListResponse(c, rows, opts, err, mqttForwarderDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountMQTTForwarders(opts)
|
total, err := store.CountMQTTForwarders(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO)
|
||||||
})
|
})
|
||||||
r.POST("/mqtt-forward/forwarders", func(c *gin.Context) {
|
r.POST("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||||
var req mqttForwarderRequest
|
var req mqttForwarderRequest
|
||||||
@@ -106,17 +109,17 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListMQTTForwardTopics(id, opts)
|
rows, err := store.ListMQTTForwardTopics(id, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, mqttForwardTopicDTO)
|
webutil.WriteListResponse(c, rows, opts, err, mqttForwardTopicDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountMQTTForwardTopics(id)
|
total, err := store.CountMQTTForwardTopics(id)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO)
|
||||||
})
|
})
|
||||||
r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
|
r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
|
||||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||||
@@ -168,7 +171,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
r.GET("/mqtt-forward/status", func(c *gin.Context) {
|
r.GET("/mqtt-forward/status", func(c *gin.Context) {
|
||||||
items := []mqttForwardRuntimeStatus{}
|
items := []RuntimeStatus{}
|
||||||
if forwarder != nil {
|
if forwarder != nil {
|
||||||
items = forwarder.Status()
|
items = forwarder.Status()
|
||||||
}
|
}
|
||||||
@@ -176,7 +179,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput {
|
func mqttForwarderInputFromRequest(req mqttForwarderRequest) storepkg.MQTTForwarderInput {
|
||||||
sourcePassword := req.SourcePassword
|
sourcePassword := req.SourcePassword
|
||||||
if req.SourcePasswordClear {
|
if req.SourcePasswordClear {
|
||||||
empty := ""
|
empty := ""
|
||||||
@@ -187,11 +190,11 @@ func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput
|
|||||||
empty := ""
|
empty := ""
|
||||||
targetPassword = &empty
|
targetPassword = &empty
|
||||||
}
|
}
|
||||||
return mqttForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS}
|
return storepkg.MQTTForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) mqttForwardTopicInput {
|
func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) storepkg.MQTTForwardTopicInput {
|
||||||
return mqttForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain}
|
return storepkg.MQTTForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
|
func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
|
||||||
@@ -203,15 +206,15 @@ func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
|
|||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadMQTTForwarder(forwarder mqttForwardReloader, id uint64) error {
|
func reloadMQTTForwarder(forwarder Reloader, id uint64) error {
|
||||||
if forwarder == nil {
|
if forwarder == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return forwarder.ReloadForwarder(id)
|
return forwarder.ReloadForwarder(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwarderRecord, err error, afterSuccess func() error) {
|
func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwarderRecord, err error, afterSuccess func() error) {
|
||||||
if errors.Is(err, errMQTTForwarderAlreadyExists) {
|
if errors.Is(err, storepkg.ErrMQTTForwarderAlreadyExists) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -232,8 +235,8 @@ func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwa
|
|||||||
c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)})
|
c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *mqttForwardTopicRecord, err error, afterSuccess func() error) {
|
func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwardTopicRecord, err error, afterSuccess func() error) {
|
||||||
if errors.Is(err, errMQTTForwardTopicAlreadyExists) {
|
if errors.Is(err, storepkg.ErrMQTTForwardTopicAlreadyExists) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -272,10 +275,10 @@ func writeMQTTForwardDeleteResponse(c *gin.Context, err error, afterSuccess func
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqttForwarderDTO(row mqttForwarderRecord) gin.H {
|
func mqttForwarderDTO(row storepkg.MQTTForwarderRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "name": row.Name, "enabled": row.Enabled, "source_host": row.SourceHost, "source_port": row.SourcePort, "source_username": row.SourceUsername, "source_password_set": row.SourcePassword != "", "source_client_id": row.SourceClientID, "source_tls": row.SourceTLS, "target_host": row.TargetHost, "target_port": row.TargetPort, "target_username": row.TargetUsername, "target_password_set": row.TargetPassword != "", "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
return gin.H{"id": row.ID, "name": row.Name, "enabled": row.Enabled, "source_host": row.SourceHost, "source_port": row.SourcePort, "source_username": row.SourceUsername, "source_password_set": row.SourcePassword != "", "source_client_id": row.SourceClientID, "source_tls": row.SourceTLS, "target_host": row.TargetHost, "target_port": row.TargetPort, "target_username": row.TargetUsername, "target_password_set": row.TargetPassword != "", "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqttForwardTopicDTO(row mqttForwardTopicRecord) gin.H {
|
func mqttForwardTopicDTO(row storepkg.MQTTForwardTopicRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "forwarder_id": row.ForwarderID, "topic": row.Topic, "enabled": row.Enabled, "direction": row.Direction, "source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix, "qos": row.QoS, "retain": row.Retain, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
return gin.H{"id": row.ID, "forwarder_id": row.ForwarderID, "topic": row.Topic, "enabled": row.Enabled, "direction": row.Direction, "source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix, "qos": row.QoS, "retain": row.Retain, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
package main
|
package mqttforward
|
||||||
|
|
||||||
import "sync/atomic"
|
import "sync/atomic"
|
||||||
|
|
||||||
type meshtasticMessageStats struct {
|
type Stats struct {
|
||||||
forwarded atomic.Int64
|
forwarded atomic.Int64
|
||||||
dropped atomic.Int64
|
dropped atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *meshtasticMessageStats) IncForwarded() {
|
func (s *Stats) IncForwarded() {
|
||||||
if s != nil {
|
if s != nil {
|
||||||
s.forwarded.Add(1)
|
s.forwarded.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *meshtasticMessageStats) IncDropped() {
|
func (s *Stats) IncDropped() {
|
||||||
if s != nil {
|
if s != nil {
|
||||||
s.dropped.Add(1)
|
s.dropped.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *meshtasticMessageStats) Forwarded() int64 {
|
func (s *Stats) Forwarded() int64 {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return s.forwarded.Load()
|
return s.forwarded.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *meshtasticMessageStats) Dropped() int64 {
|
func (s *Stats) Dropped() int64 {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package mqttforward
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -23,19 +24,19 @@ const (
|
|||||||
mqttForwardLoopMaxEntries = 10000
|
mqttForwardLoopMaxEntries = 10000
|
||||||
)
|
)
|
||||||
|
|
||||||
type mqttForwardReloader interface {
|
type Reloader interface {
|
||||||
ReloadForwarder(id uint64) error
|
ReloadForwarder(id uint64) error
|
||||||
StopForwarder(id uint64)
|
StopForwarder(id uint64)
|
||||||
Status() []mqttForwardRuntimeStatus
|
Status() []RuntimeStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
type mqttForwardManager struct {
|
type Manager struct {
|
||||||
store *store
|
store *storepkg.Store
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
runners map[uint64]*mqttForwardRunner
|
runners map[uint64]*runner
|
||||||
}
|
}
|
||||||
|
|
||||||
type mqttForwardRuntimeStatus struct {
|
type RuntimeStatus struct {
|
||||||
ForwarderID uint64 `json:"forwarder_id"`
|
ForwarderID uint64 `json:"forwarder_id"`
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
SourceConnected bool `json:"source_connected"`
|
SourceConnected bool `json:"source_connected"`
|
||||||
@@ -46,8 +47,8 @@ type mqttForwardRuntimeStatus struct {
|
|||||||
MessagesDropped uint64 `json:"messages_dropped"`
|
MessagesDropped uint64 `json:"messages_dropped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type mqttForwardRunner struct {
|
type runner struct {
|
||||||
config mqttForwarderConfig
|
config storepkg.MQTTForwarderConfig
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
source pahomqtt.Client
|
source pahomqtt.Client
|
||||||
@@ -63,11 +64,11 @@ type mqttForwardRunner struct {
|
|||||||
loopCache map[string]time.Time
|
loopCache map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMQTTForwardManager(store *store) *mqttForwardManager {
|
func NewManager(store *storepkg.Store) *Manager {
|
||||||
return &mqttForwardManager{store: store, runners: make(map[uint64]*mqttForwardRunner)}
|
return &Manager{store: store, runners: make(map[uint64]*runner)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mqttForwardManager) StartFromStore() error {
|
func (m *Manager) StartFromStore() error {
|
||||||
configs, err := m.store.ListEnabledMQTTForwarderConfigs()
|
configs, err := m.store.ListEnabledMQTTForwarderConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -76,7 +77,7 @@ func (m *mqttForwardManager) StartFromStore() error {
|
|||||||
if len(cfg.Topics) == 0 {
|
if len(cfg.Topics) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
runner := newMQTTForwardRunner(cfg)
|
runner := newRunner(cfg)
|
||||||
runner.Start()
|
runner.Start()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.runners[cfg.Forwarder.ID] = runner
|
m.runners[cfg.Forwarder.ID] = runner
|
||||||
@@ -85,7 +86,7 @@ func (m *mqttForwardManager) StartFromStore() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
|
func (m *Manager) ReloadForwarder(id uint64) error {
|
||||||
m.StopForwarder(id)
|
m.StopForwarder(id)
|
||||||
cfg, err := m.store.GetMQTTForwarderConfig(id)
|
cfg, err := m.store.GetMQTTForwarderConfig(id)
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -97,7 +98,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
|
|||||||
if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 {
|
if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
runner := newMQTTForwardRunner(*cfg)
|
runner := newRunner(*cfg)
|
||||||
runner.Start()
|
runner.Start()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.runners[id] = runner
|
m.runners[id] = runner
|
||||||
@@ -105,7 +106,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mqttForwardManager) StopForwarder(id uint64) {
|
func (m *Manager) StopForwarder(id uint64) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
runner := m.runners[id]
|
runner := m.runners[id]
|
||||||
delete(m.runners, id)
|
delete(m.runners, id)
|
||||||
@@ -115,9 +116,9 @@ func (m *mqttForwardManager) StopForwarder(id uint64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mqttForwardManager) StopAll() {
|
func (m *Manager) StopAll() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
runners := make([]*mqttForwardRunner, 0, len(m.runners))
|
runners := make([]*runner, 0, len(m.runners))
|
||||||
for id, runner := range m.runners {
|
for id, runner := range m.runners {
|
||||||
runners = append(runners, runner)
|
runners = append(runners, runner)
|
||||||
delete(m.runners, id)
|
delete(m.runners, id)
|
||||||
@@ -128,14 +129,14 @@ func (m *mqttForwardManager) StopAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus {
|
func (m *Manager) Status() []RuntimeStatus {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
runners := make([]*mqttForwardRunner, 0, len(m.runners))
|
runners := make([]*runner, 0, len(m.runners))
|
||||||
for _, runner := range m.runners {
|
for _, runner := range m.runners {
|
||||||
runners = append(runners, runner)
|
runners = append(runners, runner)
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
items := make([]mqttForwardRuntimeStatus, 0, len(runners))
|
items := make([]RuntimeStatus, 0, len(runners))
|
||||||
for _, runner := range runners {
|
for _, runner := range runners {
|
||||||
items = append(items, runner.Status())
|
items = append(items, runner.Status())
|
||||||
}
|
}
|
||||||
@@ -143,19 +144,19 @@ func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMQTTForwardRunner(config mqttForwarderConfig) *mqttForwardRunner {
|
func newRunner(config storepkg.MQTTForwarderConfig) *runner {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &mqttForwardRunner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)}
|
return &runner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) Start() {
|
func (r *runner) Start() {
|
||||||
r.source = r.newClient(true)
|
r.source = r.newClient(true)
|
||||||
r.target = r.newClient(false)
|
r.target = r.newClient(false)
|
||||||
r.connectClient(r.target, "target")
|
r.connectClient(r.target, "target")
|
||||||
r.connectClient(r.source, "source")
|
r.connectClient(r.source, "source")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) Stop() {
|
func (r *runner) Stop() {
|
||||||
r.cancel()
|
r.cancel()
|
||||||
if r.source != nil && r.source.IsConnected() {
|
if r.source != nil && r.source.IsConnected() {
|
||||||
r.source.Disconnect(250)
|
r.source.Disconnect(250)
|
||||||
@@ -165,11 +166,11 @@ func (r *mqttForwardRunner) Stop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus {
|
func (r *runner) Status() RuntimeStatus {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
started := r.startedAt
|
started := r.startedAt
|
||||||
return mqttForwardRuntimeStatus{
|
return RuntimeStatus{
|
||||||
ForwarderID: r.config.Forwarder.ID,
|
ForwarderID: r.config.Forwarder.ID,
|
||||||
Running: true,
|
Running: true,
|
||||||
SourceConnected: r.sourceConnected,
|
SourceConnected: r.sourceConnected,
|
||||||
@@ -181,7 +182,7 @@ func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client {
|
func (r *runner) newClient(source bool) pahomqtt.Client {
|
||||||
forwarder := r.config.Forwarder
|
forwarder := r.config.Forwarder
|
||||||
host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS
|
host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS
|
||||||
role := "source"
|
role := "source"
|
||||||
@@ -222,7 +223,7 @@ func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client {
|
|||||||
return pahomqtt.NewClient(opts)
|
return pahomqtt.NewClient(opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string) {
|
func (r *runner) connectClient(client pahomqtt.Client, label string) {
|
||||||
token := client.Connect()
|
token := client.Connect()
|
||||||
if !token.WaitTimeout(2 * time.Second) {
|
if !token.WaitTimeout(2 * time.Second) {
|
||||||
r.setError(label + " connect pending")
|
r.setError(label + " connect pending")
|
||||||
@@ -233,11 +234,11 @@ func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) {
|
func (r *runner) subscribe(client pahomqtt.Client, source bool) {
|
||||||
for _, topic := range r.config.Topics {
|
for _, topic := range r.config.Topics {
|
||||||
filter := topic.Topic
|
filter := topic.Topic
|
||||||
if !source {
|
if !source {
|
||||||
if topic.Direction != mqttForwardDirectionBidirectional {
|
if topic.Direction != storepkg.MQTTForwardDirectionBidirectional {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix)
|
filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix)
|
||||||
@@ -256,7 +257,7 @@ func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopicRecord, msg pahomqtt.Message) {
|
func (r *runner) forwardMessage(fromSource bool, rule storepkg.MQTTForwardTopicRecord, msg pahomqtt.Message) {
|
||||||
if r.ctx.Err() != nil {
|
if r.ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -269,7 +270,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
toTopic := fromTopic
|
toTopic := fromTopic
|
||||||
forwardDirection := mqttForwardDirectionSourceToTarget
|
forwardDirection := storepkg.MQTTForwardDirectionSourceToTarget
|
||||||
if fromSource {
|
if fromSource {
|
||||||
toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix)
|
toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix)
|
||||||
} else {
|
} else {
|
||||||
@@ -284,7 +285,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
|
|||||||
reverseDirection := mqttForwardDirectionTargetToSource
|
reverseDirection := mqttForwardDirectionTargetToSource
|
||||||
if !fromSource {
|
if !fromSource {
|
||||||
target = r.source
|
target = r.source
|
||||||
reverseDirection = mqttForwardDirectionSourceToTarget
|
reverseDirection = storepkg.MQTTForwardDirectionSourceToTarget
|
||||||
}
|
}
|
||||||
r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain)
|
r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain)
|
||||||
token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload())
|
token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload())
|
||||||
@@ -301,7 +302,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
|
|||||||
r.incForwarded()
|
r.incForwarded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) setConnected(source bool, connected bool) {
|
func (r *runner) setConnected(source bool, connected bool) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
if source {
|
if source {
|
||||||
@@ -311,25 +312,25 @@ func (r *mqttForwardRunner) setConnected(source bool, connected bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) setError(message string) {
|
func (r *runner) setError(message string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.lastError = message
|
r.lastError = message
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) incForwarded() {
|
func (r *runner) incForwarded() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.messagesForwarded++
|
r.messagesForwarded++
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) incDropped() {
|
func (r *runner) incDropped() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.messagesDropped++
|
r.messagesDropped++
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool {
|
func (r *runner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool {
|
||||||
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -346,7 +347,7 @@ func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, p
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mqttForwardRunner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) {
|
func (r *runner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) {
|
||||||
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
+14
-11
@@ -1,9 +1,11 @@
|
|||||||
package main
|
package runtimesettings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
||||||
@@ -11,12 +13,13 @@ const llmQueueEnabledLabel = "Enable LLM message queue"
|
|||||||
const llmIncludeChannelLabel = "Include channel messages in LLM queue"
|
const llmIncludeChannelLabel = "Include channel messages in LLM queue"
|
||||||
|
|
||||||
type runtimeSettingsRequest struct {
|
type runtimeSettingsRequest struct {
|
||||||
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
||||||
LLMQueueEnabled bool `json:"llm_queue_enabled"`
|
LLMQueueEnabled bool `json:"llm_queue_enabled"`
|
||||||
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
|
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) {
|
// RegisterRoutes 把 GET /runtime-settings 与 PUT /runtime-settings 挂到给定路由组下。
|
||||||
|
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, settings *Cache) {
|
||||||
r.GET("/runtime-settings", func(c *gin.Context) {
|
r.GET("/runtime-settings", func(c *gin.Context) {
|
||||||
snapshot, err := store.GetRuntimeSettings()
|
snapshot, err := store.GetRuntimeSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,15 +35,15 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := store.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil {
|
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil {
|
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil {
|
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,10 +62,10 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H {
|
func runtimeSettingsDTO(settings storepkg.RuntimeSettingsSnapshot) gin.H {
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"allow_encrypted_forwarding": settings.AllowEncryptedForwarding,
|
"allow_encrypted_forwarding": settings.AllowEncryptedForwarding,
|
||||||
"llm_queue_enabled": settings.LLMQueueEnabled,
|
"llm_queue_enabled": settings.LLMQueueEnabled,
|
||||||
"llm_include_channel_messages": settings.LLMIncludeChannel,
|
"llm_include_channel_messages": settings.LLMIncludeChannel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package runtimesettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache 把 runtime_settings 表中常用的开关缓存到内存中,避免每次拦截热路径
|
||||||
|
// 都查 DB。AdminRoute 修改后通过 Reload 重新加载。
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
settings storepkg.RuntimeSettingsSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 从 store 中加载初始快照并返回缓存。
|
||||||
|
func New(s *storepkg.Store) (*Cache, error) {
|
||||||
|
cache := &Cache{}
|
||||||
|
if err := cache.Reload(s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload 重新读取数据库快照覆盖当前值。
|
||||||
|
func (c *Cache) Reload(s *storepkg.Store) error {
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("store is required")
|
||||||
|
}
|
||||||
|
settings, err := s.GetRuntimeSettings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.settings = settings
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot 返回当前快照的副本(结构体拷贝,调用方可以安全持有)。
|
||||||
|
func (c *Cache) Snapshot() storepkg.RuntimeSettingsSnapshot {
|
||||||
|
if c == nil {
|
||||||
|
return storepkg.RuntimeSettingsSnapshot{}
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowEncryptedForwarding 是 mqtt 转发热路径上常被检查的标志位的快捷读法。
|
||||||
|
func (c *Cache) AllowEncryptedForwarding() bool {
|
||||||
|
return c.Snapshot().AllowEncryptedForwarding
|
||||||
|
}
|
||||||
+15
-6
@@ -1,20 +1,29 @@
|
|||||||
package main
|
package runtimesettings
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/store/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestStore(t *testing.T) *storepkg.Store {
|
||||||
|
return testutil.OpenStore(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRuntimeSettingsCacheReload(t *testing.T) {
|
func TestRuntimeSettingsCacheReload(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
cache, err := newRuntimeSettingsCache(st)
|
cache, err := New(st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newRuntimeSettingsCache() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
if cache.AllowEncryptedForwarding() {
|
if cache.AllowEncryptedForwarding() {
|
||||||
t.Fatalf("AllowEncryptedForwarding() = true, want false")
|
t.Fatalf("AllowEncryptedForwarding() = true, want false")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
|
if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
|
||||||
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
|
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := cache.Reload(st); err != nil {
|
if err := cache.Reload(st); err != nil {
|
||||||
@@ -24,7 +33,7 @@ func TestRuntimeSettingsCacheReload(t *testing.T) {
|
|||||||
t.Fatalf("AllowEncryptedForwarding() = false, want true")
|
t.Fatalf("AllowEncryptedForwarding() = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
|
if _, err := st.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
|
||||||
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
|
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := cache.Reload(st); err != nil {
|
if err := cache.Reload(st); err != nil {
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
package main
|
// Package sign 提供签到记录的 admin 路由与对应的 DTO/列表查询。
|
||||||
|
//
|
||||||
|
// 拆离自原来 main 包的 admin_sign_routes.go 与 web.go 中的 signDTO /
|
||||||
|
// signDayCountDTO;其它 admin 路由也通过 SignDTO / SignDayCountDTO 复用。
|
||||||
|
package sign
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -8,6 +12,9 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type signRequest struct {
|
type signRequest struct {
|
||||||
@@ -18,19 +25,20 @@ type signRequest struct {
|
|||||||
SignTime string `json:"sign_time"`
|
SignTime string `json:"sign_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminSignRoutes(r gin.IRouter, store *store) {
|
// RegisterAdminRoutes 在 admin 路由组下挂 sign CRUD 端点。
|
||||||
|
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||||
r.GET("/signs", func(c *gin.Context) {
|
r.GET("/signs", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := webutil.ParseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListSigns(opts)
|
rows, err := store.ListSigns(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, signDTO)
|
webutil.WriteListResponse(c, rows, opts, err, SignDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountSigns(opts)
|
total, err := store.CountSigns(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, signDTO)
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, SignDTO)
|
||||||
})
|
})
|
||||||
r.POST("/signs", func(c *gin.Context) {
|
r.POST("/signs", func(c *gin.Context) {
|
||||||
var req signRequest
|
var req signRequest
|
||||||
@@ -42,7 +50,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row, err := store.CreateSign(req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime)
|
row, err := store.CreateSign(req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime)
|
||||||
writeSignMutationResponse(c, http.StatusCreated, row, err)
|
writeSignMutationResponse(c, http.StatusCreated, row, err)
|
||||||
})
|
})
|
||||||
r.PUT("/signs/:id", func(c *gin.Context) {
|
r.PUT("/signs/:id", func(c *gin.Context) {
|
||||||
@@ -59,7 +67,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row, err := store.UpdateSign(id, req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime)
|
row, err := store.UpdateSign(id, req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime)
|
||||||
writeSignMutationResponse(c, http.StatusOK, row, err)
|
writeSignMutationResponse(c, http.StatusOK, row, err)
|
||||||
})
|
})
|
||||||
r.DELETE("/signs/:id", func(c *gin.Context) {
|
r.DELETE("/signs/:id", func(c *gin.Context) {
|
||||||
@@ -101,7 +109,7 @@ func parseSignRequestTime(c *gin.Context, value string) (time.Time, bool) {
|
|||||||
return parsed, true
|
return parsed, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err error) {
|
func writeSignMutationResponse(c *gin.Context, status int, row *storepkg.SignRecord, err error) {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
|
||||||
return
|
return
|
||||||
@@ -110,5 +118,15 @@ func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(status, gin.H{"item": signDTO(*row)})
|
c.JSON(status, gin.H{"item": SignDTO(*row)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignDTO 把 SignRecord 转成给前端的视图。
|
||||||
|
func SignDTO(row storepkg.SignRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": webutil.PtrString(row.LongName), "short_name": webutil.PtrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignDayCountDTO 把按日聚合的签到数量转成视图。
|
||||||
|
func SignDayCountDTO(row storepkg.SignDayCount) gin.H {
|
||||||
|
return gin.H{"date": row.Date, "count": row.Count}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,14 +10,14 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const forbiddenWordMatchContains = "contains"
|
const ForbiddenWordMatchContains = "contains"
|
||||||
|
|
||||||
var errBlockingAlreadyExists = errors.New("blocking rule already exists")
|
var ErrBlockingAlreadyExists = errors.New("blocking rule already exists")
|
||||||
|
|
||||||
func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error) {
|
func (s *Store) ListNodeBlocking(opts ListOptions) ([]NodeBlockingRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []nodeBlockingRecord
|
var rows []NodeBlockingRecord
|
||||||
q := s.db.Model(&nodeBlockingRecord{}).
|
q := s.db.Model(&NodeBlockingRecord{}).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -25,17 +25,17 @@ func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error)
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountNodeBlocking(opts listOptions) (int64, error) {
|
func (s *Store) CountNodeBlocking(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
return total, s.db.Model(&nodeBlockingRecord{}).Count(&total).Error
|
return total, s.db.Model(&NodeBlockingRecord{}).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListEnabledNodeBlocking() ([]nodeBlockingRecord, error) {
|
func (s *Store) ListEnabledNodeBlocking() ([]NodeBlockingRecord, error) {
|
||||||
var rows []nodeBlockingRecord
|
var rows []NodeBlockingRecord
|
||||||
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) {
|
func (s *Store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*NodeBlockingRecord, error) {
|
||||||
nodeID = strings.TrimSpace(nodeID)
|
nodeID = strings.TrimSpace(nodeID)
|
||||||
if nodeID == "" {
|
if nodeID == "" {
|
||||||
return nil, fmt.Errorf("node id is required")
|
return nil, fmt.Errorf("node id is required")
|
||||||
@@ -43,14 +43,14 @@ func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string,
|
|||||||
if err := s.ensureNodeBlockingUnique(0, nodeID); err != nil {
|
if err := s.ensureNodeBlockingUnique(0, nodeID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row := nodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
row := NodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||||
if err := s.db.Create(&row).Error; err != nil {
|
if err := s.db.Create(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) {
|
func (s *Store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*NodeBlockingRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("blocking rule id is required")
|
return nil, fmt.Errorf("blocking rule id is required")
|
||||||
}
|
}
|
||||||
@@ -65,14 +65,14 @@ func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, rea
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updates := map[string]any{"node_id": nodeID, "node_num": nodeNum, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
updates := map[string]any{"node_id": nodeID, "node_num": nodeNum, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
||||||
if err := s.db.Model(&nodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&NodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.getNodeBlockingByID(id)
|
return s.getNodeBlockingByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteNodeBlocking(id uint64) error {
|
func (s *Store) DeleteNodeBlocking(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&nodeBlockingRecord{})
|
result := s.db.Where("id = ?", id).Delete(&NodeBlockingRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -82,10 +82,10 @@ func (s *store) DeleteNodeBlocking(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) {
|
func (s *Store) ListIPBlocking(opts ListOptions) ([]IPBlockingRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []ipBlockingRecord
|
var rows []IPBlockingRecord
|
||||||
q := s.db.Model(&ipBlockingRecord{}).
|
q := s.db.Model(&IPBlockingRecord{}).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -93,17 +93,17 @@ func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) {
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountIPBlocking(opts listOptions) (int64, error) {
|
func (s *Store) CountIPBlocking(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
return total, s.db.Model(&ipBlockingRecord{}).Count(&total).Error
|
return total, s.db.Model(&IPBlockingRecord{}).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListEnabledIPBlocking() ([]ipBlockingRecord, error) {
|
func (s *Store) ListEnabledIPBlocking() ([]IPBlockingRecord, error) {
|
||||||
var rows []ipBlockingRecord
|
var rows []IPBlockingRecord
|
||||||
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) {
|
func (s *Store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*IPBlockingRecord, error) {
|
||||||
value, err := normalizeIPBlockingValue(ipValue)
|
value, err := normalizeIPBlockingValue(ipValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -111,14 +111,14 @@ func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*
|
|||||||
if err := s.ensureIPBlockingUnique(0, value); err != nil {
|
if err := s.ensureIPBlockingUnique(0, value); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row := ipBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
row := IPBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||||
if err := s.db.Create(&row).Error; err != nil {
|
if err := s.db.Create(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) {
|
func (s *Store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*IPBlockingRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("blocking rule id is required")
|
return nil, fmt.Errorf("blocking rule id is required")
|
||||||
}
|
}
|
||||||
@@ -133,14 +133,14 @@ func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updates := map[string]any{"ip_value": value, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
updates := map[string]any{"ip_value": value, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
||||||
if err := s.db.Model(&ipBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&IPBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.getIPBlockingByID(id)
|
return s.getIPBlockingByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteIPBlocking(id uint64) error {
|
func (s *Store) DeleteIPBlocking(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&ipBlockingRecord{})
|
result := s.db.Where("id = ?", id).Delete(&IPBlockingRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -150,10 +150,10 @@ func (s *store) DeleteIPBlocking(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBlockingRecord, error) {
|
func (s *Store) ListForbiddenWordBlocking(opts ListOptions) ([]ForbiddenWordBlockingRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []forbiddenWordBlockingRecord
|
var rows []ForbiddenWordBlockingRecord
|
||||||
q := s.db.Model(&forbiddenWordBlockingRecord{}).
|
q := s.db.Model(&ForbiddenWordBlockingRecord{}).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -161,17 +161,17 @@ func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBloc
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountForbiddenWordBlocking(opts listOptions) (int64, error) {
|
func (s *Store) CountForbiddenWordBlocking(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
return total, s.db.Model(&forbiddenWordBlockingRecord{}).Count(&total).Error
|
return total, s.db.Model(&ForbiddenWordBlockingRecord{}).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListEnabledForbiddenWordBlocking() ([]forbiddenWordBlockingRecord, error) {
|
func (s *Store) ListEnabledForbiddenWordBlocking() ([]ForbiddenWordBlockingRecord, error) {
|
||||||
var rows []forbiddenWordBlockingRecord
|
var rows []ForbiddenWordBlockingRecord
|
||||||
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) {
|
func (s *Store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*ForbiddenWordBlockingRecord, error) {
|
||||||
word = strings.TrimSpace(word)
|
word = strings.TrimSpace(word)
|
||||||
if word == "" {
|
if word == "" {
|
||||||
return nil, fmt.Errorf("forbidden word is required")
|
return nil, fmt.Errorf("forbidden word is required")
|
||||||
@@ -183,14 +183,14 @@ func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitiv
|
|||||||
if err := s.ensureForbiddenWordBlockingUnique(0, word); err != nil {
|
if err := s.ensureForbiddenWordBlockingUnique(0, word); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row := forbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
row := ForbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||||
if err := s.db.Create(&row).Error; err != nil {
|
if err := s.db.Create(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) {
|
func (s *Store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*ForbiddenWordBlockingRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("blocking rule id is required")
|
return nil, fmt.Errorf("blocking rule id is required")
|
||||||
}
|
}
|
||||||
@@ -209,14 +209,14 @@ func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, c
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updates := map[string]any{"word": word, "match_type": matchType, "case_sensitive": caseSensitive, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
updates := map[string]any{"word": word, "match_type": matchType, "case_sensitive": caseSensitive, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
||||||
if err := s.db.Model(&forbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&ForbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.getForbiddenWordBlockingByID(id)
|
return s.getForbiddenWordBlockingByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteForbiddenWordBlocking(id uint64) error {
|
func (s *Store) DeleteForbiddenWordBlocking(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&forbiddenWordBlockingRecord{})
|
result := s.db.Where("id = ?", id).Delete(&ForbiddenWordBlockingRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -226,39 +226,39 @@ func (s *store) DeleteForbiddenWordBlocking(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) getNodeBlockingByID(id uint64) (*nodeBlockingRecord, error) {
|
func (s *Store) getNodeBlockingByID(id uint64) (*NodeBlockingRecord, error) {
|
||||||
var row nodeBlockingRecord
|
var row NodeBlockingRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) getIPBlockingByID(id uint64) (*ipBlockingRecord, error) {
|
func (s *Store) getIPBlockingByID(id uint64) (*IPBlockingRecord, error) {
|
||||||
var row ipBlockingRecord
|
var row IPBlockingRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) getForbiddenWordBlockingByID(id uint64) (*forbiddenWordBlockingRecord, error) {
|
func (s *Store) getForbiddenWordBlockingByID(id uint64) (*ForbiddenWordBlockingRecord, error) {
|
||||||
var row forbiddenWordBlockingRecord
|
var row ForbiddenWordBlockingRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error {
|
func (s *Store) ensureNodeBlockingUnique(id uint64, nodeID string) error {
|
||||||
var existing nodeBlockingRecord
|
var existing NodeBlockingRecord
|
||||||
q := s.db.Where("node_id = ?", nodeID)
|
q := s.db.Where("node_id = ?", nodeID)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
q = q.Where("id <> ?", id)
|
q = q.Where("id <> ?", id)
|
||||||
}
|
}
|
||||||
err := q.Take(&existing).Error
|
err := q.Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errBlockingAlreadyExists
|
return ErrBlockingAlreadyExists
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
@@ -266,15 +266,15 @@ func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error {
|
func (s *Store) ensureIPBlockingUnique(id uint64, ipValue string) error {
|
||||||
var existing ipBlockingRecord
|
var existing IPBlockingRecord
|
||||||
q := s.db.Where("ip_value = ?", ipValue)
|
q := s.db.Where("ip_value = ?", ipValue)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
q = q.Where("id <> ?", id)
|
q = q.Where("id <> ?", id)
|
||||||
}
|
}
|
||||||
err := q.Take(&existing).Error
|
err := q.Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errBlockingAlreadyExists
|
return ErrBlockingAlreadyExists
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
@@ -282,15 +282,15 @@ func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureForbiddenWordBlockingUnique(id uint64, word string) error {
|
func (s *Store) ensureForbiddenWordBlockingUnique(id uint64, word string) error {
|
||||||
var existing forbiddenWordBlockingRecord
|
var existing ForbiddenWordBlockingRecord
|
||||||
q := s.db.Where("word = ?", word)
|
q := s.db.Where("word = ?", word)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
q = q.Where("id <> ?", id)
|
q = q.Where("id <> ?", id)
|
||||||
}
|
}
|
||||||
err := q.Take(&existing).Error
|
err := q.Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errBlockingAlreadyExists
|
return ErrBlockingAlreadyExists
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
@@ -316,9 +316,9 @@ func normalizeIPBlockingValue(value string) (string, error) {
|
|||||||
func normalizeForbiddenWordMatchType(matchType string) (string, error) {
|
func normalizeForbiddenWordMatchType(matchType string) (string, error) {
|
||||||
matchType = strings.TrimSpace(matchType)
|
matchType = strings.TrimSpace(matchType)
|
||||||
if matchType == "" {
|
if matchType == "" {
|
||||||
return forbiddenWordMatchContains, nil
|
return ForbiddenWordMatchContains, nil
|
||||||
}
|
}
|
||||||
if matchType != forbiddenWordMatchContains {
|
if matchType != ForbiddenWordMatchContains {
|
||||||
return "", fmt.Errorf("unsupported forbidden word match type")
|
return "", fmt.Errorf("unsupported forbidden word match type")
|
||||||
}
|
}
|
||||||
return matchType, nil
|
return matchType, nil
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -20,8 +20,8 @@ func TestNodeBlockingCRUD(t *testing.T) {
|
|||||||
t.Fatalf("created node rule = %+v, want normalized fields", rule)
|
t.Fatalf("created node rule = %+v, want normalized fields", rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
|
if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) {
|
||||||
t.Fatalf("duplicate CreateNodeBlocking() error = %v, want errBlockingAlreadyExists", err)
|
t.Fatalf("duplicate CreateNodeBlocking() error = %v, want ErrBlockingAlreadyExists", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedNum := int64(7)
|
updatedNum := int64(7)
|
||||||
@@ -33,14 +33,14 @@ func TestNodeBlockingCRUD(t *testing.T) {
|
|||||||
t.Fatalf("updated node rule = %+v, want updated fields", updated)
|
t.Fatalf("updated node rule = %+v, want updated fields", updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := st.ListNodeBlocking(listOptions{})
|
rows, err := st.ListNodeBlocking(ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListNodeBlocking() error = %v", err)
|
t.Fatalf("ListNodeBlocking() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(rows) != 1 || rows[0].ID != rule.ID {
|
if len(rows) != 1 || rows[0].ID != rule.ID {
|
||||||
t.Fatalf("ListNodeBlocking() = %+v, want one updated rule", rows)
|
t.Fatalf("ListNodeBlocking() = %+v, want one updated rule", rows)
|
||||||
}
|
}
|
||||||
total, err := st.CountNodeBlocking(listOptions{})
|
total, err := st.CountNodeBlocking(ListOptions{})
|
||||||
if err != nil || total != 1 {
|
if err != nil || total != 1 {
|
||||||
t.Fatalf("CountNodeBlocking() = %d, %v, want 1, nil", total, err)
|
t.Fatalf("CountNodeBlocking() = %d, %v, want 1, nil", total, err)
|
||||||
}
|
}
|
||||||
@@ -85,8 +85,8 @@ func TestIPBlockingCRUDAndValidation(t *testing.T) {
|
|||||||
t.Fatalf("cidr IPValue = %q, want 192.168.1.0/24", cidr.IPValue)
|
t.Fatalf("cidr IPValue = %q, want 192.168.1.0/24", cidr.IPValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
|
if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) {
|
||||||
t.Fatalf("duplicate CreateIPBlocking() error = %v, want errBlockingAlreadyExists", err)
|
t.Fatalf("duplicate CreateIPBlocking() error = %v, want ErrBlockingAlreadyExists", err)
|
||||||
}
|
}
|
||||||
if _, err := st.CreateIPBlocking("not-an-ip", "invalid", true); err == nil {
|
if _, err := st.CreateIPBlocking("not-an-ip", "invalid", true); err == nil {
|
||||||
t.Fatal("CreateIPBlocking(invalid) error = nil, want error")
|
t.Fatal("CreateIPBlocking(invalid) error = nil, want error")
|
||||||
@@ -100,14 +100,14 @@ func TestIPBlockingCRUDAndValidation(t *testing.T) {
|
|||||||
t.Fatalf("updated ip rule = %+v, want updated fields", updated)
|
t.Fatalf("updated ip rule = %+v, want updated fields", updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := st.ListIPBlocking(listOptions{})
|
rows, err := st.ListIPBlocking(ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListIPBlocking() error = %v", err)
|
t.Fatalf("ListIPBlocking() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(rows) != 2 {
|
if len(rows) != 2 {
|
||||||
t.Fatalf("ListIPBlocking() length = %d, want 2", len(rows))
|
t.Fatalf("ListIPBlocking() length = %d, want 2", len(rows))
|
||||||
}
|
}
|
||||||
total, err := st.CountIPBlocking(listOptions{})
|
total, err := st.CountIPBlocking(ListOptions{})
|
||||||
if err != nil || total != 2 {
|
if err != nil || total != 2 {
|
||||||
t.Fatalf("CountIPBlocking() = %d, %v, want 2, nil", total, err)
|
t.Fatalf("CountIPBlocking() = %d, %v, want 2, nil", total, err)
|
||||||
}
|
}
|
||||||
@@ -164,12 +164,12 @@ func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
|
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
|
||||||
}
|
}
|
||||||
if rule.Word != "spam" || rule.MatchType != forbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled {
|
if rule.Word != "spam" || rule.MatchType != ForbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled {
|
||||||
t.Fatalf("created word rule = %+v, want normalized fields", rule)
|
t.Fatalf("created word rule = %+v, want normalized fields", rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
|
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, ErrBlockingAlreadyExists) {
|
||||||
t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want errBlockingAlreadyExists", err)
|
t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want ErrBlockingAlreadyExists", err)
|
||||||
}
|
}
|
||||||
if _, err := st.CreateForbiddenWordBlocking(" ", "contains", false, "empty", true); err == nil {
|
if _, err := st.CreateForbiddenWordBlocking(" ", "contains", false, "empty", true); err == nil {
|
||||||
t.Fatal("CreateForbiddenWordBlocking(empty) error = nil, want error")
|
t.Fatal("CreateForbiddenWordBlocking(empty) error = nil, want error")
|
||||||
@@ -186,14 +186,14 @@ func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) {
|
|||||||
t.Fatalf("updated word rule = %+v, want updated fields", updated)
|
t.Fatalf("updated word rule = %+v, want updated fields", updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := st.ListForbiddenWordBlocking(listOptions{})
|
rows, err := st.ListForbiddenWordBlocking(ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListForbiddenWordBlocking() error = %v", err)
|
t.Fatalf("ListForbiddenWordBlocking() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(rows) != 1 || rows[0].ID != rule.ID {
|
if len(rows) != 1 || rows[0].ID != rule.ID {
|
||||||
t.Fatalf("ListForbiddenWordBlocking() = %+v, want one updated rule", rows)
|
t.Fatalf("ListForbiddenWordBlocking() = %+v, want one updated rule", rows)
|
||||||
}
|
}
|
||||||
total, err := st.CountForbiddenWordBlocking(listOptions{})
|
total, err := st.CountForbiddenWordBlocking(ListOptions{})
|
||||||
if err != nil || total != 1 {
|
if err != nil || total != 1 {
|
||||||
t.Fatalf("CountForbiddenWordBlocking() = %d, %v, want 1, nil", total, err)
|
t.Fatalf("CountForbiddenWordBlocking() = %d, %v, want 1, nil", total, err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,17 +10,17 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type botDirectMessageListOptions struct {
|
type BotDirectMessageListOptions struct {
|
||||||
listOptions
|
ListOptions
|
||||||
BotID uint64
|
BotID uint64
|
||||||
PeerNodeNum int64
|
PeerNodeNum int64
|
||||||
Direction string
|
Direction string
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertBotDirectMessage 把一条机器人 DM(出向或入向)写入 bot_direct_messages 表。
|
// InsertBotDirectMessage 把一条机器人 DM(出向或入向)写入 bot_direct_messages 表。
|
||||||
func (s *store) InsertBotDirectMessage(row *botDirectMessageRecord) error {
|
func (s *Store) InsertBotDirectMessage(row *BotDirectMessageRecord) error {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return fmt.Errorf("store is not configured")
|
return fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if row == nil {
|
if row == nil {
|
||||||
return fmt.Errorf("bot direct message is required")
|
return fmt.Errorf("bot direct message is required")
|
||||||
@@ -32,9 +32,9 @@ func (s *store) InsertBotDirectMessage(row *botDirectMessageRecord) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateBotDirectMessageStatus 更新一条出向 DM 的发送状态(pending → published/failed)。
|
// UpdateBotDirectMessageStatus 更新一条出向 DM 的发送状态(pending → published/failed)。
|
||||||
func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
func (s *Store) UpdateBotDirectMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return fmt.Errorf("store is not configured")
|
return fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return fmt.Errorf("bot direct message id is required")
|
return fmt.Errorf("bot direct message id is required")
|
||||||
@@ -44,7 +44,7 @@ func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string,
|
|||||||
"error": strings.TrimSpace(errText),
|
"error": strings.TrimSpace(errText),
|
||||||
"published_at": publishedAt,
|
"published_at": publishedAt,
|
||||||
}
|
}
|
||||||
result := s.db.Model(&botDirectMessageRecord{}).Where("id = ?", id).Updates(updates)
|
result := s.db.Model(&BotDirectMessageRecord{}).Where("id = ?", id).Updates(updates)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -55,9 +55,9 @@ func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListBotDirectMessagesByConversation 按 (bot, peer) 反序拉取 DM 历史,给 /admin/bot/direct 页面。
|
// ListBotDirectMessagesByConversation 按 (bot, peer) 反序拉取 DM 历史,给 /admin/bot/direct 页面。
|
||||||
func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOptions) ([]botDirectMessageRecord, error) {
|
func (s *Store) ListBotDirectMessagesByConversation(opts BotDirectMessageListOptions) ([]BotDirectMessageRecord, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return nil, fmt.Errorf("store is not configured")
|
return nil, fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if opts.BotID == 0 {
|
if opts.BotID == 0 {
|
||||||
return nil, fmt.Errorf("bot id is required")
|
return nil, fmt.Errorf("bot id is required")
|
||||||
@@ -65,9 +65,9 @@ func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOpt
|
|||||||
if opts.PeerNodeNum == 0 {
|
if opts.PeerNodeNum == 0 {
|
||||||
return nil, fmt.Errorf("peer node num is required")
|
return nil, fmt.Errorf("peer node num is required")
|
||||||
}
|
}
|
||||||
opts.listOptions = normalizeListOptions(opts.listOptions)
|
opts.ListOptions = NormalizeListOptions(opts.ListOptions)
|
||||||
var rows []botDirectMessageRecord
|
var rows []BotDirectMessageRecord
|
||||||
q := s.db.Model(&botDirectMessageRecord{}).
|
q := s.db.Model(&BotDirectMessageRecord{}).
|
||||||
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum).
|
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
@@ -86,15 +86,15 @@ func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CountBotDirectMessagesByConversation 返回会话总条数(前端无限滚动可用,可选)。
|
// CountBotDirectMessagesByConversation 返回会话总条数(前端无限滚动可用,可选)。
|
||||||
func (s *store) CountBotDirectMessagesByConversation(opts botDirectMessageListOptions) (int64, error) {
|
func (s *Store) CountBotDirectMessagesByConversation(opts BotDirectMessageListOptions) (int64, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return 0, fmt.Errorf("store is not configured")
|
return 0, fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if opts.BotID == 0 || opts.PeerNodeNum == 0 {
|
if opts.BotID == 0 || opts.PeerNodeNum == 0 {
|
||||||
return 0, fmt.Errorf("bot id and peer node num are required")
|
return 0, fmt.Errorf("bot id and peer node num are required")
|
||||||
}
|
}
|
||||||
var total int64
|
var total int64
|
||||||
q := s.db.Model(&botDirectMessageRecord{}).
|
q := s.db.Model(&BotDirectMessageRecord{}).
|
||||||
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum)
|
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum)
|
||||||
if opts.Direction != "" {
|
if opts.Direction != "" {
|
||||||
q = q.Where("direction = ?", opts.Direction)
|
q = q.Where("direction = ?", opts.Direction)
|
||||||
@@ -110,9 +110,9 @@ func (s *store) CountBotDirectMessagesByConversation(opts botDirectMessageListOp
|
|||||||
|
|
||||||
// FindBotForIncomingPKIPacket 在 bot_direct_messages 写入路径上判断接收方是否为受管 bot。
|
// FindBotForIncomingPKIPacket 在 bot_direct_messages 写入路径上判断接收方是否为受管 bot。
|
||||||
// 返回的 bot 用于填充 BotID/BotNodeID/BotNodeNum;不命中时返回 ErrRecordNotFound。
|
// 返回的 bot 用于填充 BotID/BotNodeID/BotNodeNum;不命中时返回 ErrRecordNotFound。
|
||||||
func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, error) {
|
func (s *Store) FindBotForIncomingPKIPacket(toNodeNum int64) (*BotNodeRecord, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return nil, fmt.Errorf("store is not configured")
|
return nil, fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
bot, err := s.GetBotNodeByNodeNum(toNodeNum)
|
bot, err := s.GetBotNodeByNodeNum(toNodeNum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,10 +124,10 @@ func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, er
|
|||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// botDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。
|
// BotDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。
|
||||||
// LastMessageAt / LastText / LastDirection 描述会话最后一条消息,便于按时间排序与预览;
|
// LastMessageAt / LastText / LastDirection 描述会话最后一条消息,便于按时间排序与预览;
|
||||||
// UnreadCount 仅对 inbound 计数(即未读消息数)。
|
// UnreadCount 仅对 inbound 计数(即未读消息数)。
|
||||||
type botDirectConversation struct {
|
type BotDirectConversation struct {
|
||||||
BotID uint64 `gorm:"column:bot_id"`
|
BotID uint64 `gorm:"column:bot_id"`
|
||||||
PeerNodeID string `gorm:"column:peer_node_id"`
|
PeerNodeID string `gorm:"column:peer_node_id"`
|
||||||
PeerNodeNum int64 `gorm:"column:peer_node_num"`
|
PeerNodeNum int64 `gorm:"column:peer_node_num"`
|
||||||
@@ -139,21 +139,21 @@ type botDirectConversation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListBotDirectConversations 聚合给定 bot 下的所有 (peer) 会话,返回最后一条消息及未读数。
|
// ListBotDirectConversations 聚合给定 bot 下的所有 (peer) 会话,返回最后一条消息及未读数。
|
||||||
// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 listOptions。
|
// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 ListOptions。
|
||||||
func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]botDirectConversation, error) {
|
func (s *Store) ListBotDirectConversations(botID uint64, opts ListOptions) ([]BotDirectConversation, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return nil, fmt.Errorf("store is not configured")
|
return nil, fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if botID == 0 {
|
if botID == 0 {
|
||||||
return nil, fmt.Errorf("bot id is required")
|
return nil, fmt.Errorf("bot id is required")
|
||||||
}
|
}
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []botDirectConversation
|
var rows []BotDirectConversation
|
||||||
// 先把每对会话的最后一条消息 ID 取出来,再把这条消息的元数据 join 回去;
|
// 先把每对会话的最后一条消息 ID 取出来,再把这条消息的元数据 join 回去;
|
||||||
// 同时聚合 unread_count(inbound 且 read_at IS NULL)和 total_count。
|
// 同时聚合 unread_count(inbound 且 read_at IS NULL)和 total_count。
|
||||||
// 这样的两步 join 避免在 GROUP BY 后引用非聚合列(MySQL 严格模式 / SQLite 兼容)。
|
// 这样的两步 join 避免在 GROUP BY 后引用非聚合列(MySQL 严格模式 / SQLite 兼容)。
|
||||||
subLast := s.db.Model(&botDirectMessageRecord{}).
|
subLast := s.db.Model(&BotDirectMessageRecord{}).
|
||||||
Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", botDirectMessageDirectionInbound).
|
Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", BotDirectMessageDirectionInbound).
|
||||||
Where("bot_id = ?", botID).
|
Where("bot_id = ?", botID).
|
||||||
Group("bot_id, peer_node_id, peer_node_num")
|
Group("bot_id, peer_node_id, peer_node_num")
|
||||||
q := s.db.Table("(?) AS agg", subLast).
|
q := s.db.Table("(?) AS agg", subLast).
|
||||||
@@ -167,16 +167,16 @@ func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MarkBotDirectMessagesRead 把 (bot, peer) 下未读的 inbound 消息全部标记为已读,返回更新行数。
|
// MarkBotDirectMessagesRead 把 (bot, peer) 下未读的 inbound 消息全部标记为已读,返回更新行数。
|
||||||
func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) {
|
func (s *Store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return 0, fmt.Errorf("store is not configured")
|
return 0, fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if botID == 0 || peerNodeNum == 0 {
|
if botID == 0 || peerNodeNum == 0 {
|
||||||
return 0, fmt.Errorf("bot id and peer node num are required")
|
return 0, fmt.Errorf("bot id and peer node num are required")
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
result := s.db.Model(&botDirectMessageRecord{}).
|
result := s.db.Model(&BotDirectMessageRecord{}).
|
||||||
Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, botDirectMessageDirectionInbound).
|
Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, BotDirectMessageDirectionInbound).
|
||||||
Update("read_at", &now)
|
Update("read_at", &now)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return 0, result.Error
|
return 0, result.Error
|
||||||
@@ -185,16 +185,16 @@ func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int6
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CountBotDirectUnread 返回某个 bot 全部未读 inbound 消息总数(用于头部小红点)。
|
// CountBotDirectUnread 返回某个 bot 全部未读 inbound 消息总数(用于头部小红点)。
|
||||||
func (s *store) CountBotDirectUnread(botID uint64) (int64, error) {
|
func (s *Store) CountBotDirectUnread(botID uint64) (int64, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return 0, fmt.Errorf("store is not configured")
|
return 0, fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if botID == 0 {
|
if botID == 0 {
|
||||||
return 0, fmt.Errorf("bot id is required")
|
return 0, fmt.Errorf("bot id is required")
|
||||||
}
|
}
|
||||||
var total int64
|
var total int64
|
||||||
err := s.db.Model(&botDirectMessageRecord{}).
|
err := s.db.Model(&BotDirectMessageRecord{}).
|
||||||
Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, botDirectMessageDirectionInbound).
|
Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, BotDirectMessageDirectionInbound).
|
||||||
Count(&total).Error
|
Count(&total).Error
|
||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ func (s *store) CountBotDirectUnread(botID uint64) (int64, error) {
|
|||||||
// isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。
|
// isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。
|
||||||
// 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。
|
// 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。
|
||||||
// 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。
|
// 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。
|
||||||
func isInboundBotDirectMessage(s *store, record map[string]any) bool {
|
func isInboundBotDirectMessage(s *Store, record map[string]any) bool {
|
||||||
if s == nil || record == nil {
|
if s == nil || record == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -221,10 +221,10 @@ func isInboundBotDirectMessage(s *store, record map[string]any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// insertInboundBotDirectMessage 把一条入向 PKI DM 转写入 bot_direct_messages 表。
|
// insertInboundBotDirectMessage 把一条入向 PKI DM 转写入 bot_direct_messages 表。
|
||||||
// 失败时返回错误,由 dbWriteQueue 统一打印 db_error 事件。
|
// 失败时返回错误,由 WriteQueue 统一打印 db_error 事件。
|
||||||
func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo mqttClientInfo) error {
|
func insertInboundBotDirectMessage(s *Store, record map[string]any, clientInfo MQTTClientInfo) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return fmt.Errorf("store is not configured")
|
return fmt.Errorf("Store is not configured")
|
||||||
}
|
}
|
||||||
if record == nil {
|
if record == nil {
|
||||||
return fmt.Errorf("record is required")
|
return fmt.Errorf("record is required")
|
||||||
@@ -267,13 +267,13 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
|||||||
contentPtr = &s
|
contentPtr = &s
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
dm := &botDirectMessageRecord{
|
dm := &BotDirectMessageRecord{
|
||||||
BotID: bot.ID,
|
BotID: bot.ID,
|
||||||
BotNodeID: bot.NodeID,
|
BotNodeID: bot.NodeID,
|
||||||
BotNodeNum: bot.NodeNum,
|
BotNodeNum: bot.NodeNum,
|
||||||
PeerNodeID: peerNodeID,
|
PeerNodeID: peerNodeID,
|
||||||
PeerNodeNum: int64(peerNum),
|
PeerNodeNum: int64(peerNum),
|
||||||
Direction: botDirectMessageDirectionInbound,
|
Direction: BotDirectMessageDirectionInbound,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
PacketID: int64(packetID),
|
PacketID: int64(packetID),
|
||||||
Text: text,
|
Text: text,
|
||||||
@@ -281,7 +281,7 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
|||||||
PKIEncrypted: true,
|
PKIEncrypted: true,
|
||||||
WantAck: wantAck,
|
WantAck: wantAck,
|
||||||
GatewayID: gatewayPtr,
|
GatewayID: gatewayPtr,
|
||||||
Status: botMessageStatusPublished,
|
Status: BotMessageStatusPublished,
|
||||||
ReceivedAt: &now,
|
ReceivedAt: &now,
|
||||||
ContentJSON: contentPtr,
|
ContentJSON: contentPtr,
|
||||||
}
|
}
|
||||||
@@ -292,9 +292,9 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
|||||||
|
|
||||||
// 同时将消息添加到 LLM 队列(忽略机器人自己发送的消息)
|
// 同时将消息添加到 LLM 队列(忽略机器人自己发送的消息)
|
||||||
if peerNodeID != bot.NodeID {
|
if peerNodeID != bot.NodeID {
|
||||||
longName := nullableString(record["long_name"])
|
longName := NullableString(record["long_name"])
|
||||||
shortName := nullableString(record["short_name"])
|
shortName := NullableString(record["short_name"])
|
||||||
channelID := nullableString(record["channel_id"])
|
channelID := NullableString(record["channel_id"])
|
||||||
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||||
BotID: bot.ID,
|
BotID: bot.ID,
|
||||||
BotNodeID: bot.NodeID,
|
BotNodeID: bot.NodeID,
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBotNodeByNodeNum 按节点号查找受管 bot 节点;用于 PKI 解密时把 to 字段映射回本地私钥。
|
||||||
|
func (s *Store) GetBotNodeByNodeNum(nodeNum int64) (*BotNodeRecord, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, errors.New("store not configured")
|
||||||
|
}
|
||||||
|
var row BotNodeRecord
|
||||||
|
if err := s.db.Where("node_num = ?", nodeNum).Take(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupNodeInfoPublicKey 在 nodeinfo 表中按 node_num 查 X25519 公钥,
|
||||||
|
// 兼容 hex 与 base64 两种历史存储格式。
|
||||||
|
func (s *Store) LookupNodeInfoPublicKey(nodeNum uint32) ([]byte, bool) {
|
||||||
|
var row NodeInfoRecord
|
||||||
|
if err := s.db.Where("node_num = ?", int64(nodeNum)).Take(&row).Error; err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if row.PublicKey == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(*row.PublicKey)
|
||||||
|
if value == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == 32 {
|
||||||
|
return decoded, true
|
||||||
|
}
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == 32 {
|
||||||
|
return decoded, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ecdh"
|
"crypto/ecdh"
|
||||||
@@ -17,19 +17,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
botDefaultTopicPrefix = "msh/CN"
|
BotDefaultTopicPrefix = "msh/CN"
|
||||||
botDefaultPSK = "AQ=="
|
BotDefaultPSK = "AQ=="
|
||||||
botDefaultNodeInfoBroadcastSeconds = int64(3600)
|
BotDefaultNodeInfoBroadcastSeconds = int64(3600)
|
||||||
botMessageTypeChannel = "channel"
|
BotMessageTypeChannel = "channel"
|
||||||
botMessageTypeDirect = "direct"
|
BotMessageTypeDirect = "direct"
|
||||||
botMessageStatusPending = "pending"
|
BotMessageStatusPending = "pending"
|
||||||
botMessageStatusPublished = "published"
|
BotMessageStatusPublished = "published"
|
||||||
botMessageStatusFailed = "failed"
|
BotMessageStatusFailed = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errBotNodeAlreadyExists = errors.New("bot node already exists")
|
var ErrBotNodeAlreadyExists = errors.New("bot node already exists")
|
||||||
|
|
||||||
type botNodeInput struct {
|
type BotNodeInput struct {
|
||||||
NodeNum *int64
|
NodeNum *int64
|
||||||
LongName string
|
LongName string
|
||||||
ShortName string
|
ShortName string
|
||||||
@@ -43,17 +43,17 @@ type botNodeInput struct {
|
|||||||
LLMIncludeChannelMessages bool
|
LLMIncludeChannelMessages bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type botMessageListOptions struct {
|
type BotMessageListOptions struct {
|
||||||
listOptions
|
ListOptions
|
||||||
BotID uint64
|
BotID uint64
|
||||||
MessageType string
|
MessageType string
|
||||||
ChannelID string
|
ChannelID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListBotNodes(opts listOptions) ([]botNodeRecord, error) {
|
func (s *Store) ListBotNodes(opts ListOptions) ([]BotNodeRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []botNodeRecord
|
var rows []BotNodeRecord
|
||||||
q := s.db.Model(&botNodeRecord{}).
|
q := s.db.Model(&BotNodeRecord{}).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -61,20 +61,20 @@ func (s *store) ListBotNodes(opts listOptions) ([]botNodeRecord, error) {
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountBotNodes(opts listOptions) (int64, error) {
|
func (s *Store) CountBotNodes(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
return total, s.db.Model(&botNodeRecord{}).Count(&total).Error
|
return total, s.db.Model(&BotNodeRecord{}).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetBotNode(id uint64) (*botNodeRecord, error) {
|
func (s *Store) GetBotNode(id uint64) (*BotNodeRecord, error) {
|
||||||
var row botNodeRecord
|
var row BotNodeRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) {
|
func (s *Store) CreateBotNode(input BotNodeInput) (*BotNodeRecord, error) {
|
||||||
row, err := s.normalizedBotNodeRecord(input)
|
row, err := s.normalizedBotNodeRecord(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -94,7 +94,7 @@ func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) {
|
|||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, error) {
|
func (s *Store) UpdateBotNode(id uint64, input BotNodeInput) (*BotNodeRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("bot node id is required")
|
return nil, fmt.Errorf("bot node id is required")
|
||||||
}
|
}
|
||||||
@@ -132,14 +132,14 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er
|
|||||||
"llm_include_channel_messages": row.LLMIncludeChannelMessages,
|
"llm_include_channel_messages": row.LLMIncludeChannelMessages,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now(),
|
||||||
}
|
}
|
||||||
if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.GetBotNode(id)
|
return s.GetBotNode(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteBotNode(id uint64) error {
|
func (s *Store) DeleteBotNode(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&botNodeRecord{})
|
result := s.db.Where("id = ?", id).Delete(&BotNodeRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -149,13 +149,13 @@ func (s *store) DeleteBotNode(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) InsertBotMessage(row *botMessageRecord) error {
|
func (s *Store) InsertBotMessage(row *BotMessageRecord) error {
|
||||||
return s.db.Create(row).Error
|
return s.db.Create(row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
func (s *Store) UpdateBotMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
||||||
updates := map[string]any{"status": status, "error": strings.TrimSpace(errText), "published_at": publishedAt}
|
updates := map[string]any{"status": status, "error": strings.TrimSpace(errText), "published_at": publishedAt}
|
||||||
result := s.db.Model(&botMessageRecord{}).Where("id = ?", id).Updates(updates)
|
result := s.db.Model(&BotMessageRecord{}).Where("id = ?", id).Updates(updates)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -165,8 +165,8 @@ func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publis
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error {
|
func (s *Store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error {
|
||||||
result := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()})
|
result := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) {
|
func (s *Store) RegenerateBotNodeKeys(id uint64) (*BotNodeRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("bot node id is required")
|
return nil, fmt.Errorf("bot node id is required")
|
||||||
}
|
}
|
||||||
@@ -188,16 +188,16 @@ func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updates := map[string]any{"public_key": row.PublicKey, "private_key": row.PrivateKey, "updated_at": time.Now()}
|
updates := map[string]any{"public_key": row.PublicKey, "private_key": row.PrivateKey, "updated_at": time.Now()}
|
||||||
if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.GetBotNode(id)
|
return s.GetBotNode(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord, error) {
|
func (s *Store) ListBotMessages(opts BotMessageListOptions) ([]BotMessageRecord, error) {
|
||||||
opts.listOptions = normalizeListOptions(opts.listOptions)
|
opts.ListOptions = NormalizeListOptions(opts.ListOptions)
|
||||||
var rows []botMessageRecord
|
var rows []BotMessageRecord
|
||||||
q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts).
|
q := applyBotMessageFilters(s.db.Model(&BotMessageRecord{}), opts).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -205,13 +205,13 @@ func (s *store) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord,
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountBotMessages(opts botMessageListOptions) (int64, error) {
|
func (s *Store) CountBotMessages(opts BotMessageListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts)
|
q := applyBotMessageFilters(s.db.Model(&BotMessageRecord{}), opts)
|
||||||
return total, q.Count(&total).Error
|
return total, q.Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyBotMessageFilters(q *gorm.DB, opts botMessageListOptions) *gorm.DB {
|
func applyBotMessageFilters(q *gorm.DB, opts BotMessageListOptions) *gorm.DB {
|
||||||
if opts.BotID != 0 {
|
if opts.BotID != 0 {
|
||||||
q = q.Where("bot_id = ?", opts.BotID)
|
q = q.Where("bot_id = ?", opts.BotID)
|
||||||
}
|
}
|
||||||
@@ -230,20 +230,20 @@ func applyBotMessageFilters(q *gorm.DB, opts botMessageListOptions) *gorm.DB {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, error) {
|
func (s *Store) normalizedBotNodeRecord(input BotNodeInput) (*BotNodeRecord, error) {
|
||||||
longName := strings.TrimSpace(input.LongName)
|
longName := strings.TrimSpace(input.LongName)
|
||||||
shortName := strings.TrimSpace(input.ShortName)
|
shortName := strings.TrimSpace(input.ShortName)
|
||||||
channelID := strings.TrimSpace(input.DefaultChannelID)
|
channelID := strings.TrimSpace(input.DefaultChannelID)
|
||||||
psk := strings.TrimSpace(input.PSK)
|
psk := strings.TrimSpace(input.PSK)
|
||||||
if psk == "" {
|
if psk == "" {
|
||||||
psk = botDefaultPSK
|
psk = BotDefaultPSK
|
||||||
}
|
}
|
||||||
if _, err := mqtpp.ExpandPSK(psk); err != nil {
|
if _, err := mqtpp.ExpandPSK(psk); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
topicPrefix := strings.Trim(strings.TrimSpace(input.TopicPrefix), "/")
|
topicPrefix := strings.Trim(strings.TrimSpace(input.TopicPrefix), "/")
|
||||||
if topicPrefix == "" {
|
if topicPrefix == "" {
|
||||||
topicPrefix = botDefaultTopicPrefix
|
topicPrefix = BotDefaultTopicPrefix
|
||||||
}
|
}
|
||||||
if longName == "" {
|
if longName == "" {
|
||||||
return nil, fmt.Errorf("long name is required")
|
return nil, fmt.Errorf("long name is required")
|
||||||
@@ -262,7 +262,7 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
|
|||||||
}
|
}
|
||||||
interval := input.NodeInfoBroadcastIntervalSeconds
|
interval := input.NodeInfoBroadcastIntervalSeconds
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = botDefaultNodeInfoBroadcastSeconds
|
interval = BotDefaultNodeInfoBroadcastSeconds
|
||||||
}
|
}
|
||||||
if interval < 60 {
|
if interval < 60 {
|
||||||
return nil, fmt.Errorf("nodeinfo broadcast interval must be at least 60 seconds")
|
return nil, fmt.Errorf("nodeinfo broadcast interval must be at least 60 seconds")
|
||||||
@@ -277,13 +277,13 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
|
|||||||
} else {
|
} else {
|
||||||
nodeNum = *input.NodeNum
|
nodeNum = *input.NodeNum
|
||||||
}
|
}
|
||||||
if err := validateBotNodeNum(nodeNum); err != nil {
|
if err := ValidateBotNodeNum(nodeNum); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval, LLMQueueEnabled: input.LLMQueueEnabled, LLMIncludeChannelMessages: input.LLMIncludeChannelMessages}, nil
|
return &BotNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval, LLMQueueEnabled: input.LLMQueueEnabled, LLMIncludeChannelMessages: input.LLMIncludeChannelMessages}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateBotNodeKeys(row *botNodeRecord) error {
|
func populateBotNodeKeys(row *BotNodeRecord) error {
|
||||||
privateKey, err := ecdh.X25519().GenerateKey(rand.Reader)
|
privateKey, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -293,7 +293,7 @@ func populateBotNodeKeys(row *botNodeRecord) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeBotPublicKey(row botNodeRecord) ([]byte, error) {
|
func DecodeBotPublicKey(row BotNodeRecord) ([]byte, error) {
|
||||||
if strings.TrimSpace(row.PublicKey) == "" {
|
if strings.TrimSpace(row.PublicKey) == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -304,31 +304,31 @@ func decodeBotPublicKey(row botNodeRecord) ([]byte, error) {
|
|||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBotNodeNum(nodeNum int64) error {
|
func ValidateBotNodeNum(nodeNum int64) error {
|
||||||
if nodeNum <= 0 || nodeNum >= int64(mqtpp.NodeNumBroadcast) {
|
if nodeNum <= 0 || nodeNum >= int64(mqtpp.NodeNumBroadcast) {
|
||||||
return fmt.Errorf("node num must be between 1 and 4294967294")
|
return fmt.Errorf("node num must be between 1 and 4294967294")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) generateBotNodeNum() (int64, error) {
|
func (s *Store) generateBotNodeNum() (int64, error) {
|
||||||
for i := 0; i < 32; i++ {
|
for i := 0; i < 32; i++ {
|
||||||
var buf [4]byte
|
var buf [4]byte
|
||||||
if _, err := rand.Read(buf[:]); err != nil {
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
nodeNum := int64(binary.LittleEndian.Uint32(buf[:]) & 0x7fffffff)
|
nodeNum := int64(binary.LittleEndian.Uint32(buf[:]) & 0x7fffffff)
|
||||||
if err := validateBotNodeNum(nodeNum); err != nil {
|
if err := ValidateBotNodeNum(nodeNum); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := s.ensureBotNodeUnique(0, mqtpp.NodeNumToID(uint32(nodeNum)), nodeNum); err != nil {
|
if err := s.ensureBotNodeUnique(0, mqtpp.NodeNumToID(uint32(nodeNum)), nodeNum); err != nil {
|
||||||
if errors.Is(err, errBotNodeAlreadyExists) {
|
if errors.Is(err, ErrBotNodeAlreadyExists) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum, mqtpp.NodeNumToID(uint32(nodeNum))); err != nil {
|
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum, mqtpp.NodeNumToID(uint32(nodeNum))); err != nil {
|
||||||
if errors.Is(err, errBotNodeAlreadyExists) {
|
if errors.Is(err, ErrBotNodeAlreadyExists) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -338,15 +338,15 @@ func (s *store) generateBotNodeNum() (int64, error) {
|
|||||||
return 0, fmt.Errorf("generate bot node num failed")
|
return 0, fmt.Errorf("generate bot node num failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) error {
|
func (s *Store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) error {
|
||||||
var existing botNodeRecord
|
var existing BotNodeRecord
|
||||||
q := s.db.Where("node_id = ? OR node_num = ?", nodeID, nodeNum)
|
q := s.db.Where("node_id = ? OR node_num = ?", nodeID, nodeNum)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
q = q.Where("id <> ?", id)
|
q = q.Where("id <> ?", id)
|
||||||
}
|
}
|
||||||
err := q.Take(&existing).Error
|
err := q.Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errBotNodeAlreadyExists
|
return ErrBotNodeAlreadyExists
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
@@ -354,8 +354,8 @@ func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error {
|
func (s *Store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error {
|
||||||
var existing nodeInfoRecord
|
var existing NodeInfoRecord
|
||||||
q := s.db.Where("node_num = ?", nodeNum)
|
q := s.db.Where("node_num = ?", nodeNum)
|
||||||
if selfNodeID != "" {
|
if selfNodeID != "" {
|
||||||
// 机器人自己广播 NodeInfo 后会以同样的 node_id/node_num 回写 nodeinfo;
|
// 机器人自己广播 NodeInfo 后会以同样的 node_id/node_num 回写 nodeinfo;
|
||||||
@@ -364,7 +364,7 @@ func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNode
|
|||||||
}
|
}
|
||||||
err := q.Take(&existing).Error
|
err := q.Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errBotNodeAlreadyExists
|
return ErrBotNodeAlreadyExists
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
+215
-207
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOpenStoreCreatesTables(t *testing.T) {
|
func TestOpenStoreCreatesTables(t *testing.T) {
|
||||||
@@ -49,7 +51,7 @@ func TestCountSignsByDayFormatsDateString(t *testing.T) {
|
|||||||
t.Fatalf("CreateSign() error = %v", err)
|
t.Fatalf("CreateSign() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := st.CountSignsByDay(listOptions{})
|
rows, err := st.CountSignsByDay(ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CountSignsByDay() error = %v", err)
|
t.Fatalf("CountSignsByDay() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -164,7 +166,7 @@ func TestListMapReportsFiltersByBounds(t *testing.T) {
|
|||||||
|
|
||||||
minLat, maxLat := 10.0, 11.0
|
minLat, maxLat := 10.0, 11.0
|
||||||
minLng, maxLng := 20.0, 21.0
|
minLng, maxLng := 20.0, 21.0
|
||||||
opts := listOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}
|
opts := ListOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng}
|
||||||
rows, err := st.ListMapReports(opts)
|
rows, err := st.ListMapReports(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListMapReports() error = %v", err)
|
t.Fatalf("ListMapReports() error = %v", err)
|
||||||
@@ -206,7 +208,7 @@ func TestListMapReportsFiltersAcrossAntimeridian(t *testing.T) {
|
|||||||
|
|
||||||
minLat, maxLat := -10.0, 10.0
|
minLat, maxLat := -10.0, 10.0
|
||||||
minLng, maxLng := 170.0, -170.0
|
minLng, maxLng := 170.0, -170.0
|
||||||
rows, err := st.ListMapReports(listOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng})
|
rows, err := st.ListMapReports(ListOptions{Limit: 100, MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListMapReports() error = %v", err)
|
t.Fatalf("ListMapReports() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -239,8 +241,8 @@ func TestListMapReportViewportReturnsPointsBelowThreshold(t *testing.T) {
|
|||||||
|
|
||||||
minLat, maxLat := -1.0, 5.0
|
minLat, maxLat := -1.0, 5.0
|
||||||
minLng, maxLng := -1.0, 5.0
|
minLng, maxLng := -1.0, 5.0
|
||||||
result, err := st.ListMapReportViewport(mapReportViewportOptions{
|
result, err := st.ListMapReportViewport(MapReportViewportOptions{
|
||||||
ListOptions: listOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng},
|
ListOptions: ListOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng},
|
||||||
Zoom: 8,
|
Zoom: 8,
|
||||||
Limit: 1000,
|
Limit: 1000,
|
||||||
ClusterThreshold: 10,
|
ClusterThreshold: 10,
|
||||||
@@ -271,8 +273,8 @@ func TestListMapReportViewportReturnsClustersAboveThreshold(t *testing.T) {
|
|||||||
|
|
||||||
minLat, maxLat := 9.0, 11.0
|
minLat, maxLat := 9.0, 11.0
|
||||||
minLng, maxLng := 19.0, 21.0
|
minLng, maxLng := 19.0, 21.0
|
||||||
result, err := st.ListMapReportViewport(mapReportViewportOptions{
|
result, err := st.ListMapReportViewport(MapReportViewportOptions{
|
||||||
ListOptions: listOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng},
|
ListOptions: ListOptions{MinLat: &minLat, MaxLat: &maxLat, MinLng: &minLng, MaxLng: &maxLng},
|
||||||
Zoom: 4,
|
Zoom: 4,
|
||||||
Limit: 1000,
|
Limit: 1000,
|
||||||
ClusterThreshold: 2,
|
ClusterThreshold: 2,
|
||||||
@@ -498,7 +500,7 @@ func TestEnsureDefaultAdminCreatesAdminUser(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetUserByUsername() error = %v", err)
|
t.Fatalf("GetUserByUsername() error = %v", err)
|
||||||
}
|
}
|
||||||
if user.Role != adminRole {
|
if user.Role != AdminRole {
|
||||||
t.Fatalf("role = %q, want admin", user.Role)
|
t.Fatalf("role = %q, want admin", user.Role)
|
||||||
}
|
}
|
||||||
if user.PasswordHash == "admin" || user.PasswordHash == "" {
|
if user.PasswordHash == "admin" || user.PasswordHash == "" {
|
||||||
@@ -536,7 +538,7 @@ func TestCreateAdminUserCreatesHashedAdmin(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAdminUser() error = %v", err)
|
t.Fatalf("CreateAdminUser() error = %v", err)
|
||||||
}
|
}
|
||||||
if user.Username != "new-admin" || user.Role != adminRole {
|
if user.Username != "new-admin" || user.Role != AdminRole {
|
||||||
t.Fatalf("user = %#v, want new-admin admin", user)
|
t.Fatalf("user = %#v, want new-admin admin", user)
|
||||||
}
|
}
|
||||||
if user.PasswordHash == "secret" || !verifyPassword(user.PasswordHash, "secret") {
|
if user.PasswordHash == "secret" || !verifyPassword(user.PasswordHash, "secret") {
|
||||||
@@ -551,8 +553,8 @@ func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) {
|
|||||||
if _, err := st.CreateAdminUser("new-admin", "secret"); err != nil {
|
if _, err := st.CreateAdminUser("new-admin", "secret"); err != nil {
|
||||||
t.Fatalf("first CreateAdminUser() error = %v", err)
|
t.Fatalf("first CreateAdminUser() error = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := st.CreateAdminUser("new-admin", "secret"); !errors.Is(err, errUserAlreadyExists) {
|
if _, err := st.CreateAdminUser("new-admin", "secret"); !errors.Is(err, ErrUserAlreadyExists) {
|
||||||
t.Fatalf("duplicate CreateAdminUser() error = %v, want errUserAlreadyExists", err)
|
t.Fatalf("duplicate CreateAdminUser() error = %v, want ErrUserAlreadyExists", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,14 +593,14 @@ func TestInsertAndListLoginLogs(t *testing.T) {
|
|||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
userID := uint64(1)
|
userID := uint64(1)
|
||||||
if err := st.InsertLoginLog(loginLogRecord{Username: "admin", UserID: &userID, Success: true, Reason: "success", RemoteAddr: "127.0.0.1:1234", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil {
|
if err := st.InsertLoginLog(LoginLogRecord{Username: "admin", UserID: &userID, Success: true, Reason: "success", RemoteAddr: "127.0.0.1:1234", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil {
|
||||||
t.Fatalf("InsertLoginLog(success) error = %v", err)
|
t.Fatalf("InsertLoginLog(success) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertLoginLog(loginLogRecord{Username: "admin", Success: false, Reason: "invalid username or password", RemoteAddr: "127.0.0.1:1235", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil {
|
if err := st.InsertLoginLog(LoginLogRecord{Username: "admin", Success: false, Reason: "invalid username or password", RemoteAddr: "127.0.0.1:1235", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil {
|
||||||
t.Fatalf("InsertLoginLog(failure) error = %v", err)
|
t.Fatalf("InsertLoginLog(failure) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logs, err := st.ListLoginLogs(listOptions{Limit: 10})
|
logs, err := st.ListLoginLogs(ListOptions{Limit: 10})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListLoginLogs() error = %v", err)
|
t.Fatalf("ListLoginLogs() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -621,7 +623,7 @@ func TestInsertDiscardDetailsStoresRawBase64AndClientInfo(t *testing.T) {
|
|||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
raw := []byte{0xff, 0x00, 0x01}
|
raw := []byte{0xff, 0x00, 0x01}
|
||||||
clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
||||||
record := map[string]any{"topic": "msh/US/test", "error": "protobuf decode failed", "payload_len": len(raw)}
|
record := map[string]any{"topic": "msh/US/test", "error": "protobuf decode failed", "payload_len": len(raw)}
|
||||||
if err := st.InsertDiscardDetails(record, raw, clientInfo); err != nil {
|
if err := st.InsertDiscardDetails(record, raw, clientInfo); err != nil {
|
||||||
t.Fatalf("InsertDiscardDetails() error = %v", err)
|
t.Fatalf("InsertDiscardDetails() error = %v", err)
|
||||||
@@ -647,13 +649,13 @@ func TestListDiscardDetailsOrdersNewestFirst(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if err := st.InsertDiscardDetails(map[string]any{"topic": "first", "error": "first"}, []byte{1}, mqttClientInfo{}); err != nil {
|
if err := st.InsertDiscardDetails(map[string]any{"topic": "first", "error": "first"}, []byte{1}, MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("first InsertDiscardDetails() error = %v", err)
|
t.Fatalf("first InsertDiscardDetails() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertDiscardDetails(map[string]any{"topic": "second", "error": "second"}, []byte{2}, mqttClientInfo{}); err != nil {
|
if err := st.InsertDiscardDetails(map[string]any{"topic": "second", "error": "second"}, []byte{2}, MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("second InsertDiscardDetails() error = %v", err)
|
t.Fatalf("second InsertDiscardDetails() error = %v", err)
|
||||||
}
|
}
|
||||||
rows, err := st.ListDiscardDetails(listOptions{Limit: 10})
|
rows, err := st.ListDiscardDetails(ListOptions{Limit: 10})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListDiscardDetails() error = %v", err)
|
t.Fatalf("ListDiscardDetails() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -669,7 +671,7 @@ func TestInsertTextMessageAppendsRows(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
||||||
if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil {
|
if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil {
|
||||||
t.Fatalf("first InsertTextMessage() error = %v", err)
|
t.Fatalf("first InsertTextMessage() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -710,7 +712,7 @@ func TestDeleteTextMessageDeletesRows(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if err := st.InsertTextMessage(textMessageTestRecord("hello"), mqttClientInfo{}); err != nil {
|
if err := st.InsertTextMessage(textMessageTestRecord("hello"), MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("InsertTextMessage() error = %v", err)
|
t.Fatalf("InsertTextMessage() error = %v", err)
|
||||||
}
|
}
|
||||||
var id uint64
|
var id uint64
|
||||||
@@ -736,7 +738,7 @@ func TestInsertTextMessageStoresClientInfo(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
clientInfo := MQTTClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
||||||
if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil {
|
if err := st.InsertTextMessage(textMessageTestRecord("hello"), clientInfo); err != nil {
|
||||||
t.Fatalf("InsertTextMessage() error = %v", err)
|
t.Fatalf("InsertTextMessage() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -756,7 +758,7 @@ func TestInsertTextMessageStoresPayloadHex(t *testing.T) {
|
|||||||
|
|
||||||
record := textMessageTestRecord(nil)
|
record := textMessageTestRecord(nil)
|
||||||
record["payload_hex"] = "fffefd"
|
record["payload_hex"] = "fffefd"
|
||||||
if err := st.InsertTextMessage(record, mqttClientInfo{}); err != nil {
|
if err := st.InsertTextMessage(record, MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("InsertTextMessage() error = %v", err)
|
t.Fatalf("InsertTextMessage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,16 +779,16 @@ func TestInsertTextMessageRequiresFields(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if err := st.InsertTextMessage(map[string]any{"type": "nodeinfo"}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "text_message") {
|
if err := st.InsertTextMessage(map[string]any{"type": "nodeinfo"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "text_message") {
|
||||||
t.Fatalf("wrong type error = %v, want text_message error", err)
|
t.Fatalf("wrong type error = %v, want text_message error", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from_num": 1, "topic": "msh/test"}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "from") {
|
if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from_num": 1, "topic": "msh/test"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "from") {
|
||||||
t.Fatalf("missing from error = %v, want from error", err)
|
t.Fatalf("missing from error = %v, want from error", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "topic": "msh/test"}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "from_num") {
|
if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "topic": "msh/test"}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "from_num") {
|
||||||
t.Fatalf("missing from_num error = %v, want from_num error", err)
|
t.Fatalf("missing from_num error = %v, want from_num error", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "from_num": 1}, mqttClientInfo{}); err == nil || !strings.Contains(err.Error(), "topic") {
|
if err := st.InsertTextMessage(map[string]any{"type": "text_message", "from": "!00000001", "from_num": 1}, MQTTClientInfo{}); err == nil || !strings.Contains(err.Error(), "topic") {
|
||||||
t.Fatalf("missing topic error = %v, want topic error", err)
|
t.Fatalf("missing topic error = %v, want topic error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -795,7 +797,7 @@ func TestInsertPositionAppendsRows(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
clientInfo := mqttClientInfo{ClientID: "client-1", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
clientInfo := MQTTClientInfo{ClientID: "client-1", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
||||||
if err := st.InsertPosition(positionTestRecord(), clientInfo); err != nil {
|
if err := st.InsertPosition(positionTestRecord(), clientInfo); err != nil {
|
||||||
t.Fatalf("first InsertPosition() error = %v", err)
|
t.Fatalf("first InsertPosition() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -826,7 +828,7 @@ func TestInsertPositionCreatesMapReportWhenMissing(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if err := st.InsertPosition(positionTestRecord(), mqttClientInfo{}); err != nil {
|
if err := st.InsertPosition(positionTestRecord(), MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("InsertPosition() error = %v", err)
|
t.Fatalf("InsertPosition() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,7 +856,7 @@ func TestInsertPositionUpdatesExistingMapReportCoordinates(t *testing.T) {
|
|||||||
position["longitude"] = 120.75
|
position["longitude"] = 120.75
|
||||||
position["altitude"] = int32(88)
|
position["altitude"] = int32(88)
|
||||||
position["precision_bits"] = uint32(10)
|
position["precision_bits"] = uint32(10)
|
||||||
if err := st.InsertPosition(position, mqttClientInfo{}); err != nil {
|
if err := st.InsertPosition(position, MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("InsertPosition() error = %v", err)
|
t.Fatalf("InsertPosition() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,7 +875,7 @@ func TestInsertTelemetryAppendsRowsAndStoresMetricsJSON(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if err := st.InsertTelemetry(telemetryTestRecord(), mqttClientInfo{}); err != nil {
|
if err := st.InsertTelemetry(telemetryTestRecord(), MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("InsertTelemetry() error = %v", err)
|
t.Fatalf("InsertTelemetry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,16 +898,16 @@ func TestInsertRoutingAndTracerouteAppendRows(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if err := st.InsertRouting(routingTestRecord(), mqttClientInfo{}); err != nil {
|
if err := st.InsertRouting(routingTestRecord(), MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("first InsertRouting() error = %v", err)
|
t.Fatalf("first InsertRouting() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertRouting(routingTestRecord(), mqttClientInfo{}); err != nil {
|
if err := st.InsertRouting(routingTestRecord(), MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("second InsertRouting() error = %v", err)
|
t.Fatalf("second InsertRouting() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertTraceroute(tracerouteTestRecord(), mqttClientInfo{}); err != nil {
|
if err := st.InsertTraceroute(tracerouteTestRecord(), MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("first InsertTraceroute() error = %v", err)
|
t.Fatalf("first InsertTraceroute() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := st.InsertTraceroute(tracerouteTestRecord(), mqttClientInfo{}); err != nil {
|
if err := st.InsertTraceroute(tracerouteTestRecord(), MQTTClientInfo{}); err != nil {
|
||||||
t.Fatalf("second InsertTraceroute() error = %v", err)
|
t.Fatalf("second InsertTraceroute() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,10 +940,10 @@ func TestInsertPacketTablesRequireFields(t *testing.T) {
|
|||||||
insert func(map[string]any) error
|
insert func(map[string]any) error
|
||||||
record map[string]any
|
record map[string]any
|
||||||
}{
|
}{
|
||||||
{name: "position", insert: func(r map[string]any) error { return st.InsertPosition(r, mqttClientInfo{}) }, record: positionTestRecord()},
|
{name: "position", insert: func(r map[string]any) error { return st.InsertPosition(r, MQTTClientInfo{}) }, record: positionTestRecord()},
|
||||||
{name: "telemetry", insert: func(r map[string]any) error { return st.InsertTelemetry(r, mqttClientInfo{}) }, record: telemetryTestRecord()},
|
{name: "telemetry", insert: func(r map[string]any) error { return st.InsertTelemetry(r, MQTTClientInfo{}) }, record: telemetryTestRecord()},
|
||||||
{name: "routing", insert: func(r map[string]any) error { return st.InsertRouting(r, mqttClientInfo{}) }, record: routingTestRecord()},
|
{name: "routing", insert: func(r map[string]any) error { return st.InsertRouting(r, MQTTClientInfo{}) }, record: routingTestRecord()},
|
||||||
{name: "traceroute", insert: func(r map[string]any) error { return st.InsertTraceroute(r, mqttClientInfo{}) }, record: tracerouteTestRecord()},
|
{name: "traceroute", insert: func(r map[string]any) error { return st.InsertTraceroute(r, MQTTClientInfo{}) }, record: tracerouteTestRecord()},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -971,19 +973,19 @@ func TestInsertPacketTablesRequireFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openTestStore(t *testing.T) *store {
|
func openTestStore(t *testing.T) *Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := openStore(databaseConfig{
|
st, err := OpenStore(config.DatabaseConfig{
|
||||||
Driver: databaseDriverSQLite,
|
Driver: config.DriverSQLite,
|
||||||
SQLite: sqliteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")},
|
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("openStore() error = %v", err)
|
t.Fatalf("OpenStore() error = %v", err)
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawTestDB(t *testing.T, st *store) *sql.DB {
|
func rawTestDB(t *testing.T, st *Store) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := st.db.DB()
|
db, err := st.db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1,94 +1,94 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import "sync"
|
import "sync"
|
||||||
|
|
||||||
type dbWriteQueue struct {
|
type WriteQueue struct {
|
||||||
store *store
|
store *Store
|
||||||
jobs chan dbWriteJob
|
jobs chan writeJob
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
type dbWriteJob struct {
|
type writeJob struct {
|
||||||
typeName string
|
typeName string
|
||||||
from any
|
from any
|
||||||
run func() error
|
run func() error
|
||||||
errorEvent map[string]any
|
errorEvent map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDBWriteQueue(store *store) *dbWriteQueue {
|
func NewWriteQueue(s *Store) *WriteQueue {
|
||||||
if store == nil {
|
if s == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
q := &dbWriteQueue{
|
q := &WriteQueue{
|
||||||
store: store,
|
store: s,
|
||||||
jobs: make(chan dbWriteJob, 1024),
|
jobs: make(chan writeJob, 1024),
|
||||||
}
|
}
|
||||||
q.wg.Add(1)
|
q.wg.Add(1)
|
||||||
go q.run()
|
go q.run()
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *dbWriteQueue) EnqueueRecord(record map[string]any, clientInfo mqttClientInfo) {
|
func (q *WriteQueue) EnqueueRecord(record map[string]any, clientInfo MQTTClientInfo) {
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record = cloneDBWriteRecord(record)
|
record = cloneDBWriteRecord(record)
|
||||||
switch record["type"] {
|
switch record["type"] {
|
||||||
case "nodeinfo":
|
case "nodeinfo":
|
||||||
q.enqueue(dbWriteJob{typeName: "nodeinfo", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "nodeinfo", from: record["from"], run: func() error {
|
||||||
return q.store.UpsertNodeInfo(record)
|
return q.store.UpsertNodeInfo(record)
|
||||||
}})
|
}})
|
||||||
case "map_report":
|
case "map_report":
|
||||||
q.enqueue(dbWriteJob{typeName: "map_report", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "map_report", from: record["from"], run: func() error {
|
||||||
return q.store.UpsertMapReport(record)
|
return q.store.UpsertMapReport(record)
|
||||||
}})
|
}})
|
||||||
case "text_message":
|
case "text_message":
|
||||||
// 私聊(PKI 加密、发往受管 bot)单独走 bot_direct_messages 表,
|
// 私聊(PKI 加密、发往受管 bot)单独走 bot_direct_messages 表,
|
||||||
// 不再写入 text_message 以避免和频道消息混在一起。
|
// 不再写入 text_message 以避免和频道消息混在一起。
|
||||||
if isInboundBotDirectMessage(q.store, record) {
|
if isInboundBotDirectMessage(q.store, record) {
|
||||||
q.enqueue(dbWriteJob{typeName: "bot_direct_message_inbound", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "bot_direct_message_inbound", from: record["from"], run: func() error {
|
||||||
return insertInboundBotDirectMessage(q.store, record, clientInfo)
|
return insertInboundBotDirectMessage(q.store, record, clientInfo)
|
||||||
}})
|
}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 频道消息同时也写入 LLM 队列(如果启用的话)
|
// 频道消息同时也写入 LLM 队列(如果启用的话)
|
||||||
q.enqueue(dbWriteJob{typeName: "llm_channel_message", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "llm_channel_message", from: record["from"], run: func() error {
|
||||||
return enqueueChannelMessageToLLM(q.store, record)
|
return enqueueChannelMessageToLLM(q.store, record)
|
||||||
}})
|
}})
|
||||||
q.enqueue(dbWriteJob{typeName: "text_message", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "text_message", from: record["from"], run: func() error {
|
||||||
return q.store.InsertTextMessage(record, clientInfo)
|
return q.store.InsertTextMessage(record, clientInfo)
|
||||||
}})
|
}})
|
||||||
case "position":
|
case "position":
|
||||||
q.enqueue(dbWriteJob{typeName: "position", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "position", from: record["from"], run: func() error {
|
||||||
return q.store.InsertPosition(record, clientInfo)
|
return q.store.InsertPosition(record, clientInfo)
|
||||||
}})
|
}})
|
||||||
case "telemetry":
|
case "telemetry":
|
||||||
q.enqueue(dbWriteJob{typeName: "telemetry", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "telemetry", from: record["from"], run: func() error {
|
||||||
return q.store.InsertTelemetry(record, clientInfo)
|
return q.store.InsertTelemetry(record, clientInfo)
|
||||||
}})
|
}})
|
||||||
case "routing":
|
case "routing":
|
||||||
q.enqueue(dbWriteJob{typeName: "routing", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "routing", from: record["from"], run: func() error {
|
||||||
return q.store.InsertRouting(record, clientInfo)
|
return q.store.InsertRouting(record, clientInfo)
|
||||||
}})
|
}})
|
||||||
case "traceroute":
|
case "traceroute":
|
||||||
q.enqueue(dbWriteJob{typeName: "traceroute", from: record["from"], run: func() error {
|
q.enqueue(writeJob{typeName: "traceroute", from: record["from"], run: func() error {
|
||||||
return q.store.InsertTraceroute(record, clientInfo)
|
return q.store.InsertTraceroute(record, clientInfo)
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *dbWriteQueue) EnqueueDiscard(record map[string]any, raw []byte, clientInfo mqttClientInfo) {
|
func (q *WriteQueue) EnqueueDiscard(record map[string]any, raw []byte, clientInfo MQTTClientInfo) {
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record = cloneDBWriteRecord(record)
|
record = cloneDBWriteRecord(record)
|
||||||
raw = append([]byte(nil), raw...)
|
raw = append([]byte(nil), raw...)
|
||||||
q.enqueue(dbWriteJob{typeName: "discard_details", from: record["from"], errorEvent: map[string]any{"event": "db_error", "type": "discard_details", "topic": record["topic"]}, run: func() error {
|
q.enqueue(writeJob{typeName: "discard_details", from: record["from"], errorEvent: map[string]any{"event": "db_error", "type": "discard_details", "topic": record["topic"]}, run: func() error {
|
||||||
return q.store.InsertDiscardDetails(record, raw, clientInfo)
|
return q.store.InsertDiscardDetails(record, raw, clientInfo)
|
||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *dbWriteQueue) Close() {
|
func (q *WriteQueue) Close() {
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,18 +96,18 @@ func (q *dbWriteQueue) Close() {
|
|||||||
q.wg.Wait()
|
q.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *dbWriteQueue) Len() int {
|
func (q *WriteQueue) Len() int {
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return len(q.jobs)
|
return len(q.jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *dbWriteQueue) enqueue(job dbWriteJob) {
|
func (q *WriteQueue) enqueue(job writeJob) {
|
||||||
q.jobs <- job
|
q.jobs <- job
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *dbWriteQueue) run() {
|
func (q *WriteQueue) run() {
|
||||||
defer q.wg.Done()
|
defer q.wg.Done()
|
||||||
for job := range q.jobs {
|
for job := range q.jobs {
|
||||||
if err := job.run(); err != nil {
|
if err := job.run(); err != nil {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -11,7 +11,7 @@ func TestDBWriteQueueWritesRecordsAsync(t *testing.T) {
|
|||||||
|
|
||||||
queue := newDBWriteQueue(st)
|
queue := newDBWriteQueue(st)
|
||||||
record := textMessageTestRecord("queued")
|
record := textMessageTestRecord("queued")
|
||||||
queue.EnqueueRecord(record, mqttClientInfo{ClientID: "client-1"})
|
queue.EnqueueRecord(record, MQTTClientInfo{ClientID: "client-1"})
|
||||||
record["text"] = "mutated after enqueue"
|
record["text"] = "mutated after enqueue"
|
||||||
queue.Close()
|
queue.Close()
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ func TestDBWriteQueueWritesDiscardAsync(t *testing.T) {
|
|||||||
|
|
||||||
queue := newDBWriteQueue(st)
|
queue := newDBWriteQueue(st)
|
||||||
record := map[string]any{"topic": "msh/test", "error": "bad packet"}
|
record := map[string]any{"topic": "msh/test", "error": "bad packet"}
|
||||||
queue.EnqueueDiscard(record, []byte{1, 2, 3}, mqttClientInfo{RemoteAddr: "127.0.0.1:1883"})
|
queue.EnqueueDiscard(record, []byte{1, 2, 3}, MQTTClientInfo{RemoteAddr: "127.0.0.1:1883"})
|
||||||
record["error"] = "mutated after enqueue"
|
record["error"] = "mutated after enqueue"
|
||||||
queue.Close()
|
queue.Close()
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ func TestDBWriteQueueWritesDiscardAsync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDBWriteQueueLen(t *testing.T) {
|
func TestDBWriteQueueLen(t *testing.T) {
|
||||||
queue := &dbWriteQueue{jobs: make(chan dbWriteJob, 1)}
|
queue := &WriteQueue{jobs: make(chan writeJob, 1)}
|
||||||
queue.enqueue(dbWriteJob{run: func() error { return nil }})
|
queue.enqueue(writeJob{run: func() error { return nil }})
|
||||||
if queue.Len() != 1 {
|
if queue.Len() != 1 {
|
||||||
t.Fatalf("queue.Len() = %d, want 1", queue.Len())
|
t.Fatalf("queue.Len() = %d, want 1", queue.Len())
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestDBWriteQueueIgnoresUnsupportedRecordType(t *testing.T) {
|
|||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
queue := newDBWriteQueue(st)
|
queue := newDBWriteQueue(st)
|
||||||
queue.EnqueueRecord(map[string]any{"type": "empty_packet", "from": "!12345678"}, mqttClientInfo{})
|
queue.EnqueueRecord(map[string]any{"type": "empty_packet", "from": "!12345678"}, MQTTClientInfo{})
|
||||||
queue.Close()
|
queue.Close()
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
@@ -72,9 +72,9 @@ func TestDBWriteQueueNilStore(t *testing.T) {
|
|||||||
if queue := newDBWriteQueue(nil); queue != nil {
|
if queue := newDBWriteQueue(nil); queue != nil {
|
||||||
t.Fatalf("newDBWriteQueue(nil) = %#v, want nil", queue)
|
t.Fatalf("newDBWriteQueue(nil) = %#v, want nil", queue)
|
||||||
}
|
}
|
||||||
var queue *dbWriteQueue
|
var queue *WriteQueue
|
||||||
queue.EnqueueRecord(textMessageTestRecord("ignored"), mqttClientInfo{})
|
queue.EnqueueRecord(textMessageTestRecord("ignored"), MQTTClientInfo{})
|
||||||
queue.EnqueueDiscard(map[string]any{"topic": "ignored"}, []byte{1}, mqttClientInfo{})
|
queue.EnqueueDiscard(map[string]any{"topic": "ignored"}, []byte{1}, MQTTClientInfo{})
|
||||||
queue.Close()
|
queue.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +85,8 @@ func TestDBWriteQueueRecordValidationErrorDoesNotStopWorker(t *testing.T) {
|
|||||||
queue := newDBWriteQueue(st)
|
queue := newDBWriteQueue(st)
|
||||||
badRecord := textMessageTestRecord("bad")
|
badRecord := textMessageTestRecord("bad")
|
||||||
delete(badRecord, "from")
|
delete(badRecord, "from")
|
||||||
queue.EnqueueRecord(badRecord, mqttClientInfo{})
|
queue.EnqueueRecord(badRecord, MQTTClientInfo{})
|
||||||
queue.EnqueueRecord(textMessageTestRecord("good"), mqttClientInfo{})
|
queue.EnqueueRecord(textMessageTestRecord("good"), MQTTClientInfo{})
|
||||||
queue.Close()
|
queue.Close()
|
||||||
|
|
||||||
var text string
|
var text string
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo mqttClientInfo) error {
|
func (s *Store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo MQTTClientInfo) error {
|
||||||
details, err := discardDetailsFromRecord(record, raw, clientInfo)
|
details, err := discardDetailsFromRecord(record, raw, clientInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -17,28 +17,28 @@ func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientIn
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo mqttClientInfo) (*discardDetailsRecord, error) {
|
func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo MQTTClientInfo) (*DiscardDetailsRecord, error) {
|
||||||
contentJSON, err := json.Marshal(record)
|
contentJSON, err := json.Marshal(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode discard_details content_json: %w", err)
|
return nil, fmt.Errorf("encode discard_details content_json: %w", err)
|
||||||
}
|
}
|
||||||
return &discardDetailsRecord{
|
return &DiscardDetailsRecord{
|
||||||
Topic: stringValue(record["topic"]),
|
Topic: stringValue(record["topic"]),
|
||||||
Error: stringValue(record["error"]),
|
Error: stringValue(record["error"]),
|
||||||
PayloadLen: int64(len(raw)),
|
PayloadLen: int64(len(raw)),
|
||||||
RawBase64: base64.StdEncoding.EncodeToString(raw),
|
RawBase64: base64.StdEncoding.EncodeToString(raw),
|
||||||
ContentJSON: string(contentJSON),
|
ContentJSON: string(contentJSON),
|
||||||
MQTTClientID: nullableStringValue(clientInfo.ClientID),
|
MQTTClientID: NullableStringValue(clientInfo.ClientID),
|
||||||
MQTTUsername: nullableStringValue(clientInfo.Username),
|
MQTTUsername: NullableStringValue(clientInfo.Username),
|
||||||
MQTTListener: nullableStringValue(clientInfo.Listener),
|
MQTTListener: NullableStringValue(clientInfo.Listener),
|
||||||
MQTTRemoteAddr: nullableStringValue(clientInfo.RemoteAddr),
|
MQTTRemoteAddr: NullableStringValue(clientInfo.RemoteAddr),
|
||||||
MQTTRemoteHost: nullableStringValue(clientInfo.RemoteHost),
|
MQTTRemoteHost: NullableStringValue(clientInfo.RemoteHost),
|
||||||
MQTTRemotePort: nullableStringValue(clientInfo.RemotePort),
|
MQTTRemotePort: NullableStringValue(clientInfo.RemotePort),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringValue(value any) string {
|
func stringValue(value any) string {
|
||||||
if s := nullableStringValue(value); s != nil {
|
if s := NullableStringValue(value); s != nil {
|
||||||
return *s
|
return *s
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const maxHelpMarkdownBytes = 200 * 1024
|
const maxHelpMarkdownBytes = 200 * 1024
|
||||||
|
|
||||||
const defaultHelpMarkdown = `## 连接地址
|
const DefaultHelpMarkdown = `## 连接地址
|
||||||
|
|
||||||
将 Meshtastic 设备连接到本服务提供的 MQTT broker。
|
将 Meshtastic 设备连接到本服务提供的 MQTT broker。
|
||||||
|
|
||||||
@@ -29,15 +29,15 @@ const defaultHelpMarkdown = `## 连接地址
|
|||||||
|
|
||||||
如果遇到 bug,请在 GitHub [提交 issue](https://github.com/wuwenfengmi1998/meshtastic_mqtt_server),或联系邮箱 [kevin@lmve.net](mailto:kevin@lmve.net)。`
|
如果遇到 bug,请在 GitHub [提交 issue](https://github.com/wuwenfengmi1998/meshtastic_mqtt_server),或联系邮箱 [kevin@lmve.net](mailto:kevin@lmve.net)。`
|
||||||
|
|
||||||
func (s *store) GetLatestHelpContent() (*helpContentRecord, error) {
|
func (s *Store) GetLatestHelpContent() (*HelpContentRecord, error) {
|
||||||
var row helpContentRecord
|
var row HelpContentRecord
|
||||||
if err := s.db.Order("id DESC").Take(&row).Error; err != nil {
|
if err := s.db.Order("id DESC").Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecord, error) {
|
func (s *Store) InsertHelpContent(markdown, createdBy string) (*HelpContentRecord, error) {
|
||||||
markdown = strings.TrimSpace(markdown)
|
markdown = strings.TrimSpace(markdown)
|
||||||
createdBy = strings.TrimSpace(createdBy)
|
createdBy = strings.TrimSpace(createdBy)
|
||||||
if markdown == "" {
|
if markdown == "" {
|
||||||
@@ -46,7 +46,7 @@ func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecor
|
|||||||
if len([]byte(markdown)) > maxHelpMarkdownBytes {
|
if len([]byte(markdown)) > maxHelpMarkdownBytes {
|
||||||
return nil, fmt.Errorf("markdown exceeds %d bytes", maxHelpMarkdownBytes)
|
return nil, fmt.Errorf("markdown exceeds %d bytes", maxHelpMarkdownBytes)
|
||||||
}
|
}
|
||||||
row := helpContentRecord{Markdown: markdown, CreatedBy: createdBy}
|
row := HelpContentRecord{Markdown: markdown, CreatedBy: createdBy}
|
||||||
if err := s.db.Create(&row).Error; err != nil {
|
if err := s.db.Create(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
// AdminRole 是管理员账号在用户表里的角色字符串。其它包通过这个常量与
|
||||||
|
// `users.role` 字段对齐,避免硬编码。
|
||||||
|
const AdminRole = "admin"
|
||||||
|
|
||||||
|
// printJSON 是 store 包内部的诊断输出钩子。当前实现为 noop——保持与
|
||||||
|
// 重构前 main.go 的行为一致;如需启用调试,可在调用方替换。
|
||||||
|
func printJSON(record map[string]any) {
|
||||||
|
_ = record
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashPassword 与 auth.go 中的散列实现保持一致(bcrypt 默认 cost)。
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uint32FromRecord 把 map[string]any 中的整型字段安全转换为 uint32。
|
||||||
|
func uint32FromRecord(value any) (uint32, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case uint32:
|
||||||
|
return v, true
|
||||||
|
case int:
|
||||||
|
if v >= 0 {
|
||||||
|
return uint32(v), true
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if v >= 0 {
|
||||||
|
return uint32(v), true
|
||||||
|
}
|
||||||
|
case uint64:
|
||||||
|
return uint32(v), true
|
||||||
|
case float64:
|
||||||
|
if v >= 0 {
|
||||||
|
return uint32(v), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// ListLLMProviders 列出所有 LLM Provider
|
// ListLLMProviders 列出所有 LLM Provider
|
||||||
func (s *store) ListLLMProviders(includeInactive bool) ([]llmProviderRecord, error) {
|
func (s *Store) ListLLMProviders(includeInactive bool) ([]LLMProviderRecord, error) {
|
||||||
var rows []llmProviderRecord
|
var rows []LLMProviderRecord
|
||||||
query := s.db.Model(&llmProviderRecord{})
|
query := s.db.Model(&LLMProviderRecord{})
|
||||||
if !includeInactive {
|
if !includeInactive {
|
||||||
query = query.Where("active = ?", true)
|
query = query.Where("active = ?", true)
|
||||||
}
|
}
|
||||||
@@ -27,8 +27,8 @@ func (s *store) ListLLMProviders(includeInactive bool) ([]llmProviderRecord, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLLMProvider 获取单个 LLM Provider
|
// GetLLMProvider 获取单个 LLM Provider
|
||||||
func (s *store) GetLLMProvider(name string) (*llmProviderRecord, error) {
|
func (s *Store) GetLLMProvider(name string) (*LLMProviderRecord, error) {
|
||||||
var record llmProviderRecord
|
var record LLMProviderRecord
|
||||||
if err := s.db.Where("name = ?", name).Take(&record).Error; err != nil {
|
if err := s.db.Where("name = ?", name).Take(&record).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,7 +39,7 @@ func (s *store) GetLLMProvider(name string) (*llmProviderRecord, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateLLMProvider 创建 LLM Provider
|
// CreateLLMProvider 创建 LLM Provider
|
||||||
func (s *store) CreateLLMProvider(record *llmProviderRecord) error {
|
func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error {
|
||||||
if err := s.db.Create(record).Error; err != nil {
|
if err := s.db.Create(record).Error; err != nil {
|
||||||
return fmt.Errorf("create llm provider %s: %w", record.Name, err)
|
return fmt.Errorf("create llm provider %s: %w", record.Name, err)
|
||||||
}
|
}
|
||||||
@@ -47,16 +47,16 @@ func (s *store) CreateLLMProvider(record *llmProviderRecord) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLLMProvider 更新 LLM Provider
|
// UpdateLLMProvider 更新 LLM Provider
|
||||||
func (s *store) UpdateLLMProvider(name string, updates map[string]any) error {
|
func (s *Store) UpdateLLMProvider(name string, updates map[string]any) error {
|
||||||
if err := s.db.Model(&llmProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&LLMProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil {
|
||||||
return fmt.Errorf("update llm provider %s: %w", name, err)
|
return fmt.Errorf("update llm provider %s: %w", name, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLLMProvider 删除 LLM Provider
|
// DeleteLLMProvider 删除 LLM Provider
|
||||||
func (s *store) DeleteLLMProvider(name string) error {
|
func (s *Store) DeleteLLMProvider(name string) error {
|
||||||
if err := s.db.Where("name = ?", name).Delete(&llmProviderRecord{}).Error; err != nil {
|
if err := s.db.Where("name = ?", name).Delete(&LLMProviderRecord{}).Error; err != nil {
|
||||||
return fmt.Errorf("delete llm provider %s: %w", name, err)
|
return fmt.Errorf("delete llm provider %s: %w", name, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -64,7 +64,7 @@ func (s *store) DeleteLLMProvider(name string) error {
|
|||||||
|
|
||||||
// EnsureDefaultLLMProvider 确保存在默认 LLM Provider 配置
|
// EnsureDefaultLLMProvider 确保存在默认 LLM Provider 配置
|
||||||
// 只有当数据库中完全没有任何 provider 配置时,才创建默认配置
|
// 只有当数据库中完全没有任何 provider 配置时,才创建默认配置
|
||||||
func (s *store) EnsureDefaultLLMProvider() error {
|
func (s *Store) EnsureDefaultLLMProvider() error {
|
||||||
// 先检查是否已经有任何 provider 配置
|
// 先检查是否已经有任何 provider 配置
|
||||||
providers, err := s.ListLLMProviders(true)
|
providers, err := s.ListLLMProviders(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,7 +74,7 @@ func (s *store) EnsureDefaultLLMProvider() error {
|
|||||||
return nil // 已有配置,不创建默认
|
return nil // 已有配置,不创建默认
|
||||||
}
|
}
|
||||||
// 创建默认配置
|
// 创建默认配置
|
||||||
defaultConfig := &llmProviderRecord{
|
defaultConfig := &LLMProviderRecord{
|
||||||
Name: "default",
|
Name: "default",
|
||||||
Active: true,
|
Active: true,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
@@ -91,8 +91,8 @@ func (s *store) EnsureDefaultLLMProvider() error {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// GetLLMToolRouter 获取当前激活的 Tool Router 配置
|
// GetLLMToolRouter 获取当前激活的 Tool Router 配置
|
||||||
func (s *store) GetLLMToolRouter() (*llmToolRouterRecord, error) {
|
func (s *Store) GetLLMToolRouter() (*LLMToolRouterRecord, error) {
|
||||||
var record llmToolRouterRecord
|
var record LLMToolRouterRecord
|
||||||
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
||||||
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -104,7 +104,7 @@ func (s *store) GetLLMToolRouter() (*llmToolRouterRecord, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateLLMToolRouter 创建 Tool Router 配置
|
// CreateLLMToolRouter 创建 Tool Router 配置
|
||||||
func (s *store) CreateLLMToolRouter(record *llmToolRouterRecord) error {
|
func (s *Store) CreateLLMToolRouter(record *LLMToolRouterRecord) error {
|
||||||
if err := s.db.Create(record).Error; err != nil {
|
if err := s.db.Create(record).Error; err != nil {
|
||||||
return fmt.Errorf("create llm tool router: %w", err)
|
return fmt.Errorf("create llm tool router: %w", err)
|
||||||
}
|
}
|
||||||
@@ -112,15 +112,15 @@ func (s *store) CreateLLMToolRouter(record *llmToolRouterRecord) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLLMToolRouter 更新 Tool Router 配置
|
// UpdateLLMToolRouter 更新 Tool Router 配置
|
||||||
func (s *store) UpdateLLMToolRouter(id uint64, updates map[string]any) error {
|
func (s *Store) UpdateLLMToolRouter(id uint64, updates map[string]any) error {
|
||||||
if err := s.db.Model(&llmToolRouterRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&LLMToolRouterRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return fmt.Errorf("update llm tool router %d: %w", id, err)
|
return fmt.Errorf("update llm tool router %d: %w", id, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureDefaultLLMToolRouter 确保存在默认 Tool Router 配置
|
// EnsureDefaultLLMToolRouter 确保存在默认 Tool Router 配置
|
||||||
func (s *store) EnsureDefaultLLMToolRouter() error {
|
func (s *Store) EnsureDefaultLLMToolRouter() error {
|
||||||
_, err := s.GetLLMToolRouter()
|
_, err := s.GetLLMToolRouter()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil // 已存在
|
return nil // 已存在
|
||||||
@@ -129,7 +129,7 @@ func (s *store) EnsureDefaultLLMToolRouter() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 创建默认配置
|
// 创建默认配置
|
||||||
defaultConfig := &llmToolRouterRecord{
|
defaultConfig := &LLMToolRouterRecord{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
OpenAIName: "",
|
OpenAIName: "",
|
||||||
Timeout: 30,
|
Timeout: 30,
|
||||||
@@ -144,8 +144,8 @@ func (s *store) EnsureDefaultLLMToolRouter() error {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// GetLLMPrimaryConfig 获取当前激活的主 AI 回复配置
|
// GetLLMPrimaryConfig 获取当前激活的主 AI 回复配置
|
||||||
func (s *store) GetLLMPrimaryConfig() (*llmPrimaryConfigRecord, error) {
|
func (s *Store) GetLLMPrimaryConfig() (*LLMPrimaryConfigRecord, error) {
|
||||||
var record llmPrimaryConfigRecord
|
var record LLMPrimaryConfigRecord
|
||||||
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
||||||
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -158,7 +158,7 @@ func (s *store) GetLLMPrimaryConfig() (*llmPrimaryConfigRecord, error) {
|
|||||||
|
|
||||||
// GetLLMPrimaryConfigSystemPrompt 获取主 AI 回复配置中的系统提示词
|
// GetLLMPrimaryConfigSystemPrompt 获取主 AI 回复配置中的系统提示词
|
||||||
// 如果没有配置或出错,返回空字符串(autoreply service 会处理这种情况)
|
// 如果没有配置或出错,返回空字符串(autoreply service 会处理这种情况)
|
||||||
func (s *store) GetLLMPrimaryConfigSystemPrompt() (string, error) {
|
func (s *Store) GetLLMPrimaryConfigSystemPrompt() (string, error) {
|
||||||
record, err := s.GetLLMPrimaryConfig()
|
record, err := s.GetLLMPrimaryConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -171,7 +171,7 @@ func (s *store) GetLLMPrimaryConfigSystemPrompt() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLLMPrimaryConfigEnableTool 获取是否启用工具调用
|
// GetLLMPrimaryConfigEnableTool 获取是否启用工具调用
|
||||||
func (s *store) GetLLMPrimaryConfigEnableTool() (bool, error) {
|
func (s *Store) GetLLMPrimaryConfigEnableTool() (bool, error) {
|
||||||
record, err := s.GetLLMPrimaryConfig()
|
record, err := s.GetLLMPrimaryConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -183,7 +183,7 @@ func (s *store) GetLLMPrimaryConfigEnableTool() (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateLLMPrimaryConfig 创建主 AI 回复配置
|
// CreateLLMPrimaryConfig 创建主 AI 回复配置
|
||||||
func (s *store) CreateLLMPrimaryConfig(record *llmPrimaryConfigRecord) error {
|
func (s *Store) CreateLLMPrimaryConfig(record *LLMPrimaryConfigRecord) error {
|
||||||
if err := s.db.Create(record).Error; err != nil {
|
if err := s.db.Create(record).Error; err != nil {
|
||||||
return fmt.Errorf("create llm primary config: %w", err)
|
return fmt.Errorf("create llm primary config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -191,15 +191,15 @@ func (s *store) CreateLLMPrimaryConfig(record *llmPrimaryConfigRecord) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLLMPrimaryConfig 更新主 AI 回复配置
|
// UpdateLLMPrimaryConfig 更新主 AI 回复配置
|
||||||
func (s *store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error {
|
func (s *Store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error {
|
||||||
if err := s.db.Model(&llmPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&LLMPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return fmt.Errorf("update llm primary config %d: %w", id, err)
|
return fmt.Errorf("update llm primary config %d: %w", id, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureDefaultLLMPrimaryConfig 确保存在默认主 AI 回复配置
|
// EnsureDefaultLLMPrimaryConfig 确保存在默认主 AI 回复配置
|
||||||
func (s *store) EnsureDefaultLLMPrimaryConfig() error {
|
func (s *Store) EnsureDefaultLLMPrimaryConfig() error {
|
||||||
_, err := s.GetLLMPrimaryConfig()
|
_, err := s.GetLLMPrimaryConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil // 已存在
|
return nil // 已存在
|
||||||
@@ -208,7 +208,7 @@ func (s *store) EnsureDefaultLLMPrimaryConfig() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 创建默认配置
|
// 创建默认配置
|
||||||
defaultConfig := &llmPrimaryConfigRecord{
|
defaultConfig := &LLMPrimaryConfigRecord{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
ProviderName: "",
|
ProviderName: "",
|
||||||
Timeout: 120,
|
Timeout: 120,
|
||||||
@@ -237,7 +237,7 @@ type LLMMessageQueueInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnqueueLLMMessage 将消息添加到 LLM 队列
|
// EnqueueLLMMessage 将消息添加到 LLM 队列
|
||||||
func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueRecord, error) {
|
func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueRecord, error) {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if input.BotID == 0 {
|
if input.BotID == 0 {
|
||||||
@@ -269,16 +269,16 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR
|
|||||||
// packet_id > 0: 用 bot_id + packet_id 去重(频道消息)
|
// packet_id > 0: 用 bot_id + packet_id 去重(频道消息)
|
||||||
// packet_id = 0: 用 bot_id + from_node_id + text 去重(私聊消息,可能没有 packet_id)
|
// packet_id = 0: 用 bot_id + from_node_id + text 去重(私聊消息,可能没有 packet_id)
|
||||||
// 只排除 pending/processing 状态的消息,允许 error 状态的消息重新入队
|
// 只排除 pending/processing 状态的消息,允许 error 状态的消息重新入队
|
||||||
var existing llmMessageQueueRecord
|
var existing LLMMessageQueueRecord
|
||||||
if input.PacketID > 0 {
|
if input.PacketID > 0 {
|
||||||
// 频道消息:用 bot_id + packet_id 去重
|
// 频道消息:用 bot_id + packet_id 去重
|
||||||
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL AND status IN (?, ?)",
|
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL AND status IN (?, ?)",
|
||||||
input.BotID, input.PacketID, llmMessageStatusPending, llmMessageStatusProcessing).
|
input.BotID, input.PacketID, LLMMessageStatusPending, LLMMessageStatusProcessing).
|
||||||
Take(&existing).Error
|
Take(&existing).Error
|
||||||
} else {
|
} else {
|
||||||
// 私聊消息:用 bot_id + from_node_id + text 去重(避免同一人连续发相同内容被拒绝)
|
// 私聊消息:用 bot_id + from_node_id + text 去重(避免同一人连续发相同内容被拒绝)
|
||||||
err = s.db.Where("bot_id = ? AND from_node_id = ? AND text = ? AND deleted_at IS NULL AND status IN (?, ?)",
|
err = s.db.Where("bot_id = ? AND from_node_id = ? AND text = ? AND deleted_at IS NULL AND status IN (?, ?)",
|
||||||
input.BotID, input.FromNodeID, input.Text, llmMessageStatusPending, llmMessageStatusProcessing).
|
input.BotID, input.FromNodeID, input.Text, LLMMessageStatusPending, LLMMessageStatusProcessing).
|
||||||
Take(&existing).Error
|
Take(&existing).Error
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -294,7 +294,7 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR
|
|||||||
if messageType == "" {
|
if messageType == "" {
|
||||||
messageType = "direct"
|
messageType = "direct"
|
||||||
}
|
}
|
||||||
record := &llmMessageQueueRecord{
|
record := &LLMMessageQueueRecord{
|
||||||
BotID: input.BotID,
|
BotID: input.BotID,
|
||||||
BotNodeID: input.BotNodeID,
|
BotNodeID: input.BotNodeID,
|
||||||
BotNodeNum: input.BotNodeNum,
|
BotNodeNum: input.BotNodeNum,
|
||||||
@@ -307,7 +307,7 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR
|
|||||||
ChannelID: input.ChannelID,
|
ChannelID: input.ChannelID,
|
||||||
Topic: input.Topic,
|
Topic: input.Topic,
|
||||||
MessageType: messageType,
|
MessageType: messageType,
|
||||||
Status: llmMessageStatusPending,
|
Status: LLMMessageStatusPending,
|
||||||
ReceivedAt: now,
|
ReceivedAt: now,
|
||||||
ContentJSON: input.ContentJSON,
|
ContentJSON: input.ContentJSON,
|
||||||
}
|
}
|
||||||
@@ -319,9 +319,9 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListLLMMessages 列出 LLM 队列消息
|
// ListLLMMessages 列出 LLM 队列消息
|
||||||
func (s *store) ListLLMMessages(opts listOptions, botID uint64, includeDeleted bool) ([]llmMessageQueueRecord, int64, error) {
|
func (s *Store) ListLLMMessages(opts ListOptions, botID uint64, includeDeleted bool) ([]LLMMessageQueueRecord, int64, error) {
|
||||||
var rows []llmMessageQueueRecord
|
var rows []LLMMessageQueueRecord
|
||||||
query := s.db.Model(&llmMessageQueueRecord{})
|
query := s.db.Model(&LLMMessageQueueRecord{})
|
||||||
|
|
||||||
if botID > 0 {
|
if botID > 0 {
|
||||||
query = query.Where("bot_id = ?", botID)
|
query = query.Where("bot_id = ?", botID)
|
||||||
@@ -352,8 +352,8 @@ func (s *store) ListLLMMessages(opts listOptions, botID uint64, includeDeleted b
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLLMMessage 获取单条 LLM 消息
|
// GetLLMMessage 获取单条 LLM 消息
|
||||||
func (s *store) GetLLMMessage(id uint64) (*llmMessageQueueRecord, error) {
|
func (s *Store) GetLLMMessage(id uint64) (*LLMMessageQueueRecord, error) {
|
||||||
var record llmMessageQueueRecord
|
var record LLMMessageQueueRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&record).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&record).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -364,50 +364,50 @@ func (s *store) GetLLMMessage(id uint64) (*llmMessageQueueRecord, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLLMMessageStatus 更新 LLM 消息状态
|
// UpdateLLMMessageStatus 更新 LLM 消息状态
|
||||||
func (s *store) UpdateLLMMessageStatus(id uint64, status string, errorMsg string) error {
|
func (s *Store) UpdateLLMMessageStatus(id uint64, status string, errorMsg string) error {
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"error": errorMsg,
|
"error": errorMsg,
|
||||||
}
|
}
|
||||||
if status == llmMessageStatusProcessed {
|
if status == LLMMessageStatusProcessed {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
updates["processed_at"] = &now
|
updates["processed_at"] = &now
|
||||||
}
|
}
|
||||||
if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&LLMMessageQueueRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return fmt.Errorf("update llm message status %d: %w", id, err)
|
return fmt.Errorf("update llm message status %d: %w", id, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftDeleteLLMMessage 软删除 LLM 消息
|
// SoftDeleteLLMMessage 软删除 LLM 消息
|
||||||
func (s *store) SoftDeleteLLMMessage(id uint64) error {
|
func (s *Store) SoftDeleteLLMMessage(id uint64) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Update("deleted_at", &now).Error; err != nil {
|
if err := s.db.Model(&LLMMessageQueueRecord{}).Where("id = ?", id).Update("deleted_at", &now).Error; err != nil {
|
||||||
return fmt.Errorf("soft delete llm message %d: %w", id, err)
|
return fmt.Errorf("soft delete llm message %d: %w", id, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftDeleteLLMMessagesByBot 软删除指定机器人的所有消息
|
// SoftDeleteLLMMessagesByBot 软删除指定机器人的所有消息
|
||||||
func (s *store) SoftDeleteLLMMessagesByBot(botID uint64) error {
|
func (s *Store) SoftDeleteLLMMessagesByBot(botID uint64) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := s.db.Model(&llmMessageQueueRecord{}).Where("bot_id = ? AND deleted_at IS NULL", botID).Update("deleted_at", &now).Error; err != nil {
|
if err := s.db.Model(&LLMMessageQueueRecord{}).Where("bot_id = ? AND deleted_at IS NULL", botID).Update("deleted_at", &now).Error; err != nil {
|
||||||
return fmt.Errorf("soft delete llm messages for bot %d: %w", botID, err)
|
return fmt.Errorf("soft delete llm messages for bot %d: %w", botID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupDeletedLLMMessages 清理已软删除超过指定时间的消息
|
// CleanupDeletedLLMMessages 清理已软删除超过指定时间的消息
|
||||||
func (s *store) CleanupDeletedLLMMessages(before time.Time) (int64, error) {
|
func (s *Store) CleanupDeletedLLMMessages(before time.Time) (int64, error) {
|
||||||
result := s.db.Where("deleted_at IS NOT NULL AND deleted_at < ?", before).Delete(&llmMessageQueueRecord{})
|
result := s.db.Where("deleted_at IS NOT NULL AND deleted_at < ?", before).Delete(&LLMMessageQueueRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return 0, fmt.Errorf("cleanup deleted llm messages: %w", result.Error)
|
return 0, fmt.Errorf("cleanup deleted llm messages: %w", result.Error)
|
||||||
}
|
}
|
||||||
return result.RowsAffected, nil
|
return result.RowsAffected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// llmMessageDTO 将数据库记录转换为 API 响应格式
|
// LLMMessageDTO 将数据库记录转换为 API 响应格式
|
||||||
func llmMessageDTO(row llmMessageQueueRecord) map[string]any {
|
func LLMMessageDTO(row LLMMessageQueueRecord) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"bot_id": row.BotID,
|
"bot_id": row.BotID,
|
||||||
@@ -432,7 +432,7 @@ func llmMessageDTO(row llmMessageQueueRecord) map[string]any {
|
|||||||
|
|
||||||
// enqueueChannelMessageToLLM 将频道消息添加到 LLM 队列
|
// enqueueChannelMessageToLLM 将频道消息添加到 LLM 队列
|
||||||
// 为每个启用了「包含频道消息」的机器人都创建一条独立的队列记录
|
// 为每个启用了「包含频道消息」的机器人都创建一条独立的队列记录
|
||||||
func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
|
func enqueueChannelMessageToLLM(s *Store, record map[string]any) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -481,7 +481,7 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
|
|||||||
|
|
||||||
// 查询所有启用了 LLM 队列且包含频道消息的机器人
|
// 查询所有启用了 LLM 队列且包含频道消息的机器人
|
||||||
// SQLite 中 numeric 布尔值用 1/0 存储,必须用整数查询
|
// SQLite 中 numeric 布尔值用 1/0 存储,必须用整数查询
|
||||||
var bots []botNodeRecord
|
var bots []BotNodeRecord
|
||||||
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", 1, 1).Find(&bots).Error
|
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", 1, 1).Find(&bots).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("query bots for channel message enqueue: %w", err)
|
return fmt.Errorf("query bots for channel message enqueue: %w", err)
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
func (s *store) InsertLoginLog(log loginLogRecord) error {
|
func (s *Store) InsertLoginLog(log LoginLogRecord) error {
|
||||||
return s.db.Create(&log).Error
|
return s.db.Create(&log).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListLoginLogs(opts listOptions) ([]loginLogRecord, error) {
|
func (s *Store) ListLoginLogs(opts ListOptions) ([]LoginLogRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []loginLogRecord
|
var rows []LoginLogRecord
|
||||||
q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
||||||
if opts.Since != nil {
|
if opts.Since != nil {
|
||||||
q = q.Where("created_at >= ?", *opts.Since)
|
q = q.Where("created_at >= ?", *opts.Since)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -22,13 +22,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errMapTileSourceAlreadyExists = errors.New("map source already exists")
|
ErrMapTileSourceAlreadyExists = errors.New("map source already exists")
|
||||||
errMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted")
|
ErrMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted")
|
||||||
errMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled")
|
ErrMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled")
|
||||||
errMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled")
|
ErrMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled")
|
||||||
)
|
)
|
||||||
|
|
||||||
type mapTileSourceInput struct {
|
type MapTileSourceInput struct {
|
||||||
Name string
|
Name string
|
||||||
URLTemplate string
|
URLTemplate string
|
||||||
Attribution string
|
Attribution string
|
||||||
@@ -38,10 +38,10 @@ type mapTileSourceInput struct {
|
|||||||
ProxyEnabled bool
|
ProxyEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) {
|
func (s *Store) ListMapTileSources(opts ListOptions) ([]MapTileSourceRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []mapTileSourceRecord
|
var rows []MapTileSourceRecord
|
||||||
q := s.db.Model(&mapTileSourceRecord{}).
|
q := s.db.Model(&MapTileSourceRecord{}).
|
||||||
Order("is_default DESC").
|
Order("is_default DESC").
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
@@ -50,14 +50,14 @@ func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, err
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountMapTileSources(opts listOptions) (int64, error) {
|
func (s *Store) CountMapTileSources(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error
|
return total, s.db.Model(&MapTileSourceRecord{}).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) {
|
func (s *Store) ListEnabledMapTileSources() ([]MapTileSourceRecord, error) {
|
||||||
var rows []mapTileSourceRecord
|
var rows []MapTileSourceRecord
|
||||||
if err := s.db.Model(&mapTileSourceRecord{}).
|
if err := s.db.Model(&MapTileSourceRecord{}).
|
||||||
Where("enabled = ?", true).
|
Where("enabled = ?", true).
|
||||||
Order("is_default DESC").
|
Order("is_default DESC").
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
@@ -66,13 +66,13 @@ func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return []mapTileSourceRecord{defaultMapTileSourceRecord()}, nil
|
return []MapTileSourceRecord{defaultMapTileSourceRecord()}, nil
|
||||||
}
|
}
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
|
func (s *Store) GetDefaultMapTileSource() (*MapTileSourceRecord, error) {
|
||||||
var row mapTileSourceRecord
|
var row MapTileSourceRecord
|
||||||
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
|
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
fallback := defaultMapTileSourceRecord()
|
fallback := defaultMapTileSourceRecord()
|
||||||
@@ -84,28 +84,28 @@ func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
|
|||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetEnabledMapTileSourceByHash(hash string) (*mapTileSourceRecord, error) {
|
func (s *Store) GetEnabledMapTileSourceByHash(hash string) (*MapTileSourceRecord, error) {
|
||||||
var row mapTileSourceRecord
|
var row MapTileSourceRecord
|
||||||
if err := s.db.Where("enabled = ? AND proxy_enabled = ? AND url_template_hash = ?", true, true, hash).Take(&row).Error; err != nil {
|
if err := s.db.Where("enabled = ? AND proxy_enabled = ? AND url_template_hash = ?", true, true, hash).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
func (s *Store) CreateMapTileSource(input MapTileSourceInput) (*MapTileSourceRecord, error) {
|
||||||
row, err := mapTileSourceFromInput(input)
|
row, err := mapTileSourceFromInput(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if row.IsDefault && !row.Enabled {
|
if row.IsDefault && !row.Enabled {
|
||||||
return nil, errMapTileSourceDefaultMustBeEnabled
|
return nil, ErrMapTileSourceDefaultMustBeEnabled
|
||||||
}
|
}
|
||||||
if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil {
|
if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if row.IsDefault {
|
if row.IsDefault {
|
||||||
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
if err := tx.Model(&MapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRec
|
|||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
func (s *Store) UpdateMapTileSource(id uint64, input MapTileSourceInput) (*MapTileSourceRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("map source id is required")
|
return nil, fmt.Errorf("map source id is required")
|
||||||
}
|
}
|
||||||
@@ -124,17 +124,17 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var updated mapTileSourceRecord
|
var updated MapTileSourceRecord
|
||||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var existing mapTileSourceRecord
|
var existing MapTileSourceRecord
|
||||||
if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil {
|
if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if existing.IsDefault && !row.Enabled {
|
if existing.IsDefault && !row.Enabled {
|
||||||
return errMapTileSourceCannotDisableDefault
|
return ErrMapTileSourceCannotDisableDefault
|
||||||
}
|
}
|
||||||
if row.IsDefault && !row.Enabled {
|
if row.IsDefault && !row.Enabled {
|
||||||
return errMapTileSourceDefaultMustBeEnabled
|
return ErrMapTileSourceDefaultMustBeEnabled
|
||||||
}
|
}
|
||||||
if !row.IsDefault && existing.IsDefault {
|
if !row.IsDefault && existing.IsDefault {
|
||||||
row.IsDefault = true
|
row.IsDefault = true
|
||||||
@@ -143,7 +143,7 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if row.IsDefault {
|
if row.IsDefault {
|
||||||
if err := tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil {
|
if err := tx.Model(&MapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
|||||||
"proxy_enabled": row.ProxyEnabled,
|
"proxy_enabled": row.ProxyEnabled,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now(),
|
||||||
}
|
}
|
||||||
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := tx.Model(&MapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Where("id = ?", id).Take(&updated).Error
|
return tx.Where("id = ?", id).Take(&updated).Error
|
||||||
@@ -168,19 +168,19 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
|||||||
return &updated, nil
|
return &updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteMapTileSource(id uint64) error {
|
func (s *Store) DeleteMapTileSource(id uint64) error {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return fmt.Errorf("map source id is required")
|
return fmt.Errorf("map source id is required")
|
||||||
}
|
}
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var row mapTileSourceRecord
|
var row MapTileSourceRecord
|
||||||
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if row.IsDefault {
|
if row.IsDefault {
|
||||||
return errMapTileSourceCannotDeleteDefault
|
return ErrMapTileSourceCannotDeleteDefault
|
||||||
}
|
}
|
||||||
result := tx.Where("id = ?", id).Delete(&mapTileSourceRecord{})
|
result := tx.Where("id = ?", id).Delete(&MapTileSourceRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -191,22 +191,22 @@ func (s *store) DeleteMapTileSource(id uint64) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) {
|
func (s *Store) SetDefaultMapTileSource(id uint64) (*MapTileSourceRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("map source id is required")
|
return nil, fmt.Errorf("map source id is required")
|
||||||
}
|
}
|
||||||
var row mapTileSourceRecord
|
var row MapTileSourceRecord
|
||||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !row.Enabled {
|
if !row.Enabled {
|
||||||
return errMapTileSourceDefaultMustBeEnabled
|
return ErrMapTileSourceDefaultMustBeEnabled
|
||||||
}
|
}
|
||||||
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
if err := tx.Model(&MapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil {
|
if err := tx.Model(&MapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Where("id = ?", id).Take(&row).Error
|
return tx.Where("id = ?", id).Take(&row).Error
|
||||||
@@ -216,10 +216,10 @@ func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error)
|
|||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) EnsureDefaultMapTileSource() error {
|
func (s *Store) EnsureDefaultMapTileSource() error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var count int64
|
var count int64
|
||||||
if err := tx.Model(&mapTileSourceRecord{}).Count(&count).Error; err != nil {
|
if err := tx.Model(&MapTileSourceRecord{}).Count(&count).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -227,28 +227,28 @@ func (s *store) EnsureDefaultMapTileSource() error {
|
|||||||
return tx.Create(&row).Error
|
return tx.Create(&row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaults []mapTileSourceRecord
|
var defaults []MapTileSourceRecord
|
||||||
if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil {
|
if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(defaults) > 0 {
|
if len(defaults) > 0 {
|
||||||
return tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error
|
return tx.Model(&MapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
var enabled mapTileSourceRecord
|
var enabled MapTileSourceRecord
|
||||||
err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error
|
err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error
|
return tx.Model(&MapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error
|
||||||
}
|
}
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
row := defaultMapTileSourceRecord()
|
row := defaultMapTileSourceRecord()
|
||||||
var existing mapTileSourceRecord
|
var existing MapTileSourceRecord
|
||||||
err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error
|
err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error
|
return tx.Model(&MapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error
|
||||||
}
|
}
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return err
|
return err
|
||||||
@@ -257,16 +257,16 @@ func (s *store) EnsureDefaultMapTileSource() error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapTileSourceHash(urlTemplate string) string {
|
func MapTileSourceHash(urlTemplate string) string {
|
||||||
h := sha256.Sum256([]byte(urlTemplate))
|
h := sha256.Sum256([]byte(urlTemplate))
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultMapTileSourceRecord() mapTileSourceRecord {
|
func defaultMapTileSourceRecord() MapTileSourceRecord {
|
||||||
return mapTileSourceRecord{
|
return MapTileSourceRecord{
|
||||||
Name: defaultMapTileSourceName,
|
Name: defaultMapTileSourceName,
|
||||||
URLTemplate: defaultMapTileSourceURLTemplate,
|
URLTemplate: defaultMapTileSourceURLTemplate,
|
||||||
URLTemplateHash: mapTileSourceHash(defaultMapTileSourceURLTemplate),
|
URLTemplateHash: MapTileSourceHash(defaultMapTileSourceURLTemplate),
|
||||||
Attribution: defaultMapTileSourceAttribution,
|
Attribution: defaultMapTileSourceAttribution,
|
||||||
MaxZoom: defaultMapTileSourceMaxZoom,
|
MaxZoom: defaultMapTileSourceMaxZoom,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -275,7 +275,7 @@ func defaultMapTileSourceRecord() mapTileSourceRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
func mapTileSourceFromInput(input MapTileSourceInput) (*MapTileSourceRecord, error) {
|
||||||
name := strings.TrimSpace(input.Name)
|
name := strings.TrimSpace(input.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, fmt.Errorf("map source name is required")
|
return nil, fmt.Errorf("map source name is required")
|
||||||
@@ -291,10 +291,10 @@ func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, err
|
|||||||
if maxZoom < 1 || maxZoom > 30 {
|
if maxZoom < 1 || maxZoom > 30 {
|
||||||
return nil, fmt.Errorf("max zoom must be between 1 and 30")
|
return nil, fmt.Errorf("max zoom must be between 1 and 30")
|
||||||
}
|
}
|
||||||
return &mapTileSourceRecord{
|
return &MapTileSourceRecord{
|
||||||
Name: name,
|
Name: name,
|
||||||
URLTemplate: urlTemplate,
|
URLTemplate: urlTemplate,
|
||||||
URLTemplateHash: mapTileSourceHash(urlTemplate),
|
URLTemplateHash: MapTileSourceHash(urlTemplate),
|
||||||
Attribution: strings.TrimSpace(input.Attribution),
|
Attribution: strings.TrimSpace(input.Attribution),
|
||||||
MaxZoom: maxZoom,
|
MaxZoom: maxZoom,
|
||||||
Enabled: input.Enabled,
|
Enabled: input.Enabled,
|
||||||
@@ -337,19 +337,19 @@ func normalizeMapTileSourceURLTemplate(value string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error {
|
func (s *Store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error {
|
||||||
return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate)
|
return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error {
|
func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error {
|
||||||
var existing mapTileSourceRecord
|
var existing MapTileSourceRecord
|
||||||
q := tx.Where("name = ? OR url_template = ?", name, urlTemplate)
|
q := tx.Where("name = ? OR url_template = ?", name, urlTemplate)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
q = q.Where("id <> ?", id)
|
q = q.Where("id <> ?", id)
|
||||||
}
|
}
|
||||||
err := q.Take(&existing).Error
|
err := q.Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errMapTileSourceAlreadyExists
|
return ErrMapTileSourceAlreadyExists
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -25,13 +25,13 @@ func TestCreateMapTileSourceValidation(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
||||||
t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error")
|
t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error")
|
||||||
}
|
}
|
||||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
||||||
t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error")
|
t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error")
|
||||||
}
|
}
|
||||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
||||||
t.Fatal("CreateMapTileSource() credentials error = nil, want error")
|
t.Fatal("CreateMapTileSource() credentials error = nil, want error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,11 +40,11 @@ func TestListEnabledMapTileSources(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
|
disabled, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
|
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
|
||||||
}
|
}
|
||||||
custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
custom, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource(custom) error = %v", err)
|
t.Fatalf("CreateMapTileSource(custom) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -76,15 +76,15 @@ func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
first, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
|
if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, ErrMapTileSourceAlreadyExists) {
|
||||||
t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err)
|
t.Fatalf("duplicate name error = %v, want ErrMapTileSourceAlreadyExists", err)
|
||||||
}
|
}
|
||||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
|
if _, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, ErrMapTileSourceAlreadyExists) {
|
||||||
t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err)
|
t.Fatalf("duplicate url error = %v, want ErrMapTileSourceAlreadyExists", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := st.SetDefaultMapTileSource(first.ID)
|
updated, err := st.SetDefaultMapTileSource(first.ID)
|
||||||
@@ -102,11 +102,11 @@ func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
|
|||||||
if oldDefault.ID != first.ID {
|
if oldDefault.ID != first.ID {
|
||||||
t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID)
|
t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID)
|
||||||
}
|
}
|
||||||
if _, err := st.UpdateMapTileSource(first.ID, mapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, errMapTileSourceCannotDisableDefault) {
|
if _, err := st.UpdateMapTileSource(first.ID, MapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, ErrMapTileSourceCannotDisableDefault) {
|
||||||
t.Fatalf("disable default error = %v, want errMapTileSourceCannotDisableDefault", err)
|
t.Fatalf("disable default error = %v, want ErrMapTileSourceCannotDisableDefault", err)
|
||||||
}
|
}
|
||||||
if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, errMapTileSourceCannotDeleteDefault) {
|
if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, ErrMapTileSourceCannotDeleteDefault) {
|
||||||
t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err)
|
t.Fatalf("delete default error = %v, want ErrMapTileSourceCannotDeleteDefault", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,11 +114,11 @@ func TestMapTileSourceHashIsSetOnCreate(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
want := mapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png")
|
want := MapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png")
|
||||||
if row.URLTemplateHash != want {
|
if row.URLTemplateHash != want {
|
||||||
t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
|
t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ func TestMapTileSourceDefaultHasHash(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
|
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
want := mapTileSourceHash(defaultMapTileSourceURLTemplate)
|
want := MapTileSourceHash(defaultMapTileSourceURLTemplate)
|
||||||
if row.URLTemplateHash != want {
|
if row.URLTemplateHash != want {
|
||||||
t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
|
t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func TestGetEnabledMapTileSourceByHash(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
|
row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ func TestGetEnabledMapTileSourceByHashProxyDisabled(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
|
row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ func TestPublicMapTileSourceDTOProxyURL(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
|
row, err := st.CreateMapTileSource(MapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -242,9 +242,9 @@ func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMapTileSourceHashFunction(t *testing.T) {
|
func TestMapTileSourceHashFunction(t *testing.T) {
|
||||||
hash1 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
|
hash1 := MapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
|
||||||
hash2 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
|
hash2 := MapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
|
||||||
hash3 := mapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png")
|
hash3 := MapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png")
|
||||||
|
|
||||||
if hash1 != hash2 {
|
if hash1 != hash2 {
|
||||||
t.Fatal("hash should be deterministic")
|
t.Fatal("hash should be deterministic")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,16 +10,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
mqttForwardDirectionSourceToTarget = "source_to_target"
|
MQTTForwardDirectionSourceToTarget = "source_to_target"
|
||||||
mqttForwardDirectionBidirectional = "bidirectional"
|
MQTTForwardDirectionBidirectional = "bidirectional"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errMQTTForwarderAlreadyExists = errors.New("mqtt forwarder already exists")
|
ErrMQTTForwarderAlreadyExists = errors.New("mqtt forwarder already exists")
|
||||||
errMQTTForwardTopicAlreadyExists = errors.New("mqtt forward topic already exists")
|
ErrMQTTForwardTopicAlreadyExists = errors.New("mqtt forward topic already exists")
|
||||||
)
|
)
|
||||||
|
|
||||||
type mqttForwarderInput struct {
|
type MQTTForwarderInput struct {
|
||||||
Name string
|
Name string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
SourceHost string
|
SourceHost string
|
||||||
@@ -36,7 +36,7 @@ type mqttForwarderInput struct {
|
|||||||
TargetTLS bool
|
TargetTLS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type mqttForwardTopicInput struct {
|
type MQTTForwardTopicInput struct {
|
||||||
Topic string
|
Topic string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Direction string
|
Direction string
|
||||||
@@ -46,15 +46,15 @@ type mqttForwardTopicInput struct {
|
|||||||
Retain bool
|
Retain bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type mqttForwarderConfig struct {
|
type MQTTForwarderConfig struct {
|
||||||
Forwarder mqttForwarderRecord
|
Forwarder MQTTForwarderRecord
|
||||||
Topics []mqttForwardTopicRecord
|
Topics []MQTTForwardTopicRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListMQTTForwarders(opts listOptions) ([]mqttForwarderRecord, error) {
|
func (s *Store) ListMQTTForwarders(opts ListOptions) ([]MQTTForwarderRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []mqttForwarderRecord
|
var rows []MQTTForwarderRecord
|
||||||
q := s.db.Model(&mqttForwarderRecord{}).
|
q := s.db.Model(&MQTTForwarderRecord{}).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -62,20 +62,20 @@ func (s *store) ListMQTTForwarders(opts listOptions) ([]mqttForwarderRecord, err
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountMQTTForwarders(opts listOptions) (int64, error) {
|
func (s *Store) CountMQTTForwarders(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
return total, s.db.Model(&mqttForwarderRecord{}).Count(&total).Error
|
return total, s.db.Model(&MQTTForwarderRecord{}).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetMQTTForwarder(id uint64) (*mqttForwarderRecord, error) {
|
func (s *Store) GetMQTTForwarder(id uint64) (*MQTTForwarderRecord, error) {
|
||||||
var row mqttForwarderRecord
|
var row MQTTForwarderRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateMQTTForwarder(input mqttForwarderInput) (*mqttForwarderRecord, error) {
|
func (s *Store) CreateMQTTForwarder(input MQTTForwarderInput) (*MQTTForwarderRecord, error) {
|
||||||
row, err := mqttForwarderFromInput(input, nil)
|
row, err := mqttForwarderFromInput(input, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -89,7 +89,7 @@ func (s *store) CreateMQTTForwarder(input mqttForwarderInput) (*mqttForwarderRec
|
|||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateMQTTForwarder(id uint64, input mqttForwarderInput) (*mqttForwarderRecord, error) {
|
func (s *Store) UpdateMQTTForwarder(id uint64, input MQTTForwarderInput) (*MQTTForwarderRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("mqtt forwarder id is required")
|
return nil, fmt.Errorf("mqtt forwarder id is required")
|
||||||
}
|
}
|
||||||
@@ -112,21 +112,21 @@ func (s *store) UpdateMQTTForwarder(id uint64, input mqttForwarderInput) (*mqttF
|
|||||||
"target_password": row.TargetPassword, "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS,
|
"target_password": row.TargetPassword, "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now(),
|
||||||
}
|
}
|
||||||
if err := s.db.Model(&mqttForwarderRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&MQTTForwarderRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.GetMQTTForwarder(id)
|
return s.GetMQTTForwarder(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteMQTTForwarder(id uint64) error {
|
func (s *Store) DeleteMQTTForwarder(id uint64) error {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return fmt.Errorf("mqtt forwarder id is required")
|
return fmt.Errorf("mqtt forwarder id is required")
|
||||||
}
|
}
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("forwarder_id = ?", id).Delete(&mqttForwardTopicRecord{}).Error; err != nil {
|
if err := tx.Where("forwarder_id = ?", id).Delete(&MQTTForwardTopicRecord{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result := tx.Where("id = ?", id).Delete(&mqttForwarderRecord{})
|
result := tx.Where("id = ?", id).Delete(&MQTTForwarderRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -137,10 +137,10 @@ func (s *store) DeleteMQTTForwarder(id uint64) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListMQTTForwardTopics(forwarderID uint64, opts listOptions) ([]mqttForwardTopicRecord, error) {
|
func (s *Store) ListMQTTForwardTopics(forwarderID uint64, opts ListOptions) ([]MQTTForwardTopicRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []mqttForwardTopicRecord
|
var rows []MQTTForwardTopicRecord
|
||||||
q := s.db.Model(&mqttForwardTopicRecord{}).
|
q := s.db.Model(&MQTTForwardTopicRecord{}).
|
||||||
Where("forwarder_id = ?", forwarderID).
|
Where("forwarder_id = ?", forwarderID).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
@@ -149,20 +149,20 @@ func (s *store) ListMQTTForwardTopics(forwarderID uint64, opts listOptions) ([]m
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountMQTTForwardTopics(forwarderID uint64) (int64, error) {
|
func (s *Store) CountMQTTForwardTopics(forwarderID uint64) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
return total, s.db.Model(&mqttForwardTopicRecord{}).Where("forwarder_id = ?", forwarderID).Count(&total).Error
|
return total, s.db.Model(&MQTTForwardTopicRecord{}).Where("forwarder_id = ?", forwarderID).Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetMQTTForwardTopic(id uint64) (*mqttForwardTopicRecord, error) {
|
func (s *Store) GetMQTTForwardTopic(id uint64) (*MQTTForwardTopicRecord, error) {
|
||||||
var row mqttForwardTopicRecord
|
var row MQTTForwardTopicRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateMQTTForwardTopic(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
func (s *Store) CreateMQTTForwardTopic(forwarderID uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) {
|
||||||
if _, err := s.GetMQTTForwarder(forwarderID); err != nil {
|
if _, err := s.GetMQTTForwarder(forwarderID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ func (s *store) CreateMQTTForwardTopic(forwarderID uint64, input mqttForwardTopi
|
|||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateMQTTForwardTopic(id uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
func (s *Store) UpdateMQTTForwardTopic(id uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("mqtt forward topic id is required")
|
return nil, fmt.Errorf("mqtt forward topic id is required")
|
||||||
}
|
}
|
||||||
@@ -199,14 +199,14 @@ func (s *store) UpdateMQTTForwardTopic(id uint64, input mqttForwardTopicInput) (
|
|||||||
"source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix,
|
"source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix,
|
||||||
"qos": row.QoS, "retain": row.Retain, "updated_at": time.Now(),
|
"qos": row.QoS, "retain": row.Retain, "updated_at": time.Now(),
|
||||||
}
|
}
|
||||||
if err := s.db.Model(&mqttForwardTopicRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&MQTTForwardTopicRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.GetMQTTForwardTopic(id)
|
return s.GetMQTTForwardTopic(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteMQTTForwardTopic(id uint64) error {
|
func (s *Store) DeleteMQTTForwardTopic(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&mqttForwardTopicRecord{})
|
result := s.db.Where("id = ?", id).Delete(&MQTTForwardTopicRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -216,39 +216,39 @@ func (s *store) DeleteMQTTForwardTopic(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetMQTTForwarderConfig(id uint64) (*mqttForwarderConfig, error) {
|
func (s *Store) GetMQTTForwarderConfig(id uint64) (*MQTTForwarderConfig, error) {
|
||||||
forwarder, err := s.GetMQTTForwarder(id)
|
forwarder, err := s.GetMQTTForwarder(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var topics []mqttForwardTopicRecord
|
var topics []MQTTForwardTopicRecord
|
||||||
if err := s.db.Where("forwarder_id = ? AND enabled = ?", id, true).Order("id ASC").Find(&topics).Error; err != nil {
|
if err := s.db.Where("forwarder_id = ? AND enabled = ?", id, true).Order("id ASC").Find(&topics).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &mqttForwarderConfig{Forwarder: *forwarder, Topics: topics}, nil
|
return &MQTTForwarderConfig{Forwarder: *forwarder, Topics: topics}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListEnabledMQTTForwarderConfigs() ([]mqttForwarderConfig, error) {
|
func (s *Store) ListEnabledMQTTForwarderConfigs() ([]MQTTForwarderConfig, error) {
|
||||||
var forwarders []mqttForwarderRecord
|
var forwarders []MQTTForwarderRecord
|
||||||
if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&forwarders).Error; err != nil {
|
if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&forwarders).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
configs := make([]mqttForwarderConfig, 0, len(forwarders))
|
configs := make([]MQTTForwarderConfig, 0, len(forwarders))
|
||||||
for _, forwarder := range forwarders {
|
for _, forwarder := range forwarders {
|
||||||
var topics []mqttForwardTopicRecord
|
var topics []MQTTForwardTopicRecord
|
||||||
if err := s.db.Where("forwarder_id = ? AND enabled = ?", forwarder.ID, true).Order("id ASC").Find(&topics).Error; err != nil {
|
if err := s.db.Where("forwarder_id = ? AND enabled = ?", forwarder.ID, true).Order("id ASC").Find(&topics).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(topics) == 0 {
|
if len(topics) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
configs = append(configs, mqttForwarderConfig{Forwarder: forwarder, Topics: topics})
|
configs = append(configs, MQTTForwarderConfig{Forwarder: forwarder, Topics: topics})
|
||||||
}
|
}
|
||||||
return configs, nil
|
return configs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureMQTTForwarderNameUnique(id uint64, name string) error {
|
func (s *Store) ensureMQTTForwarderNameUnique(id uint64, name string) error {
|
||||||
var existing mqttForwarderRecord
|
var existing MQTTForwarderRecord
|
||||||
q := s.db.Where("name = ?", name)
|
q := s.db.Where("name = ?", name)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
q = q.Where("id <> ?", id)
|
q = q.Where("id <> ?", id)
|
||||||
@@ -260,11 +260,11 @@ func (s *store) ensureMQTTForwarderNameUnique(id uint64, name string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return errMQTTForwarderAlreadyExists
|
return ErrMQTTForwarderAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic string) error {
|
func (s *Store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic string) error {
|
||||||
var existing mqttForwardTopicRecord
|
var existing MQTTForwardTopicRecord
|
||||||
q := s.db.Where("forwarder_id = ? AND topic = ?", forwarderID, topic)
|
q := s.db.Where("forwarder_id = ? AND topic = ?", forwarderID, topic)
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
q = q.Where("id <> ?", id)
|
q = q.Where("id <> ?", id)
|
||||||
@@ -276,10 +276,10 @@ func (s *store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return errMQTTForwardTopicAlreadyExists
|
return ErrMQTTForwardTopicAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRecord) (*mqttForwarderRecord, error) {
|
func mqttForwarderFromInput(input MQTTForwarderInput, existing *MQTTForwarderRecord) (*MQTTForwarderRecord, error) {
|
||||||
name := strings.TrimSpace(input.Name)
|
name := strings.TrimSpace(input.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, fmt.Errorf("mqtt forwarder name is required")
|
return nil, fmt.Errorf("mqtt forwarder name is required")
|
||||||
@@ -298,7 +298,7 @@ func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRec
|
|||||||
if err := validateMQTTForwardPort(input.TargetPort, "target port"); err != nil {
|
if err := validateMQTTForwardPort(input.TargetPort, "target port"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row := &mqttForwarderRecord{
|
row := &MQTTForwarderRecord{
|
||||||
Name: name, Enabled: input.Enabled,
|
Name: name, Enabled: input.Enabled,
|
||||||
SourceHost: sourceHost, SourcePort: input.SourcePort, SourceUsername: strings.TrimSpace(input.SourceUsername), SourceClientID: strings.TrimSpace(input.SourceClientID), SourceTLS: input.SourceTLS,
|
SourceHost: sourceHost, SourcePort: input.SourcePort, SourceUsername: strings.TrimSpace(input.SourceUsername), SourceClientID: strings.TrimSpace(input.SourceClientID), SourceTLS: input.SourceTLS,
|
||||||
TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS,
|
TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS,
|
||||||
@@ -316,7 +316,7 @@ func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRec
|
|||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqttForwardTopicFromInput(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
func mqttForwardTopicFromInput(forwarderID uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) {
|
||||||
if forwarderID == 0 {
|
if forwarderID == 0 {
|
||||||
return nil, fmt.Errorf("mqtt forwarder id is required")
|
return nil, fmt.Errorf("mqtt forwarder id is required")
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ func mqttForwardTopicFromInput(forwarderID uint64, input mqttForwardTopicInput)
|
|||||||
if input.QoS < 0 || input.QoS > 2 {
|
if input.QoS < 0 || input.QoS > 2 {
|
||||||
return nil, fmt.Errorf("qos must be 0, 1, or 2")
|
return nil, fmt.Errorf("qos must be 0, 1, or 2")
|
||||||
}
|
}
|
||||||
return &mqttForwardTopicRecord{
|
return &MQTTForwardTopicRecord{
|
||||||
ForwarderID: forwarderID, Topic: topic, Enabled: input.Enabled, Direction: direction,
|
ForwarderID: forwarderID, Topic: topic, Enabled: input.Enabled, Direction: direction,
|
||||||
SourcePrefix: strings.Trim(strings.TrimSpace(input.SourcePrefix), "/"),
|
SourcePrefix: strings.Trim(strings.TrimSpace(input.SourcePrefix), "/"),
|
||||||
TargetPrefix: strings.Trim(strings.TrimSpace(input.TargetPrefix), "/"),
|
TargetPrefix: strings.Trim(strings.TrimSpace(input.TargetPrefix), "/"),
|
||||||
@@ -349,10 +349,10 @@ func validateMQTTForwardPort(port int, label string) error {
|
|||||||
func normalizeMQTTForwardDirection(direction string) (string, error) {
|
func normalizeMQTTForwardDirection(direction string) (string, error) {
|
||||||
direction = strings.TrimSpace(direction)
|
direction = strings.TrimSpace(direction)
|
||||||
if direction == "" {
|
if direction == "" {
|
||||||
direction = mqttForwardDirectionSourceToTarget
|
direction = MQTTForwardDirectionSourceToTarget
|
||||||
}
|
}
|
||||||
switch direction {
|
switch direction {
|
||||||
case mqttForwardDirectionSourceToTarget, mqttForwardDirectionBidirectional:
|
case MQTTForwardDirectionSourceToTarget, MQTTForwardDirectionBidirectional:
|
||||||
return direction, nil
|
return direction, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("invalid mqtt forward direction")
|
return "", fmt.Errorf("invalid mqtt forward direction")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -12,45 +12,45 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
RuntimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
||||||
runtimeSettingLLMQueueEnabled = "llm.queue_enabled"
|
RuntimeSettingLLMQueueEnabled = "llm.queue_enabled"
|
||||||
runtimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages"
|
RuntimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages"
|
||||||
runtimeSettingTypeBool = "bool"
|
runtimeSettingTypeBool = "bool"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runtimeSettingsSnapshot struct {
|
type RuntimeSettingsSnapshot struct {
|
||||||
AllowEncryptedForwarding bool
|
AllowEncryptedForwarding bool
|
||||||
LLMQueueEnabled bool
|
LLMQueueEnabled bool
|
||||||
LLMIncludeChannel bool
|
LLMIncludeChannel bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
func (s *Store) GetRuntimeSettings() (RuntimeSettingsSnapshot, error) {
|
||||||
allowEncrypted, err := s.GetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false)
|
allowEncrypted, err := s.GetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return runtimeSettingsSnapshot{}, err
|
return RuntimeSettingsSnapshot{}, err
|
||||||
}
|
}
|
||||||
llmQueueEnabled, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, true)
|
llmQueueEnabled, err := s.GetBoolRuntimeSetting(RuntimeSettingLLMQueueEnabled, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return runtimeSettingsSnapshot{}, err
|
return RuntimeSettingsSnapshot{}, err
|
||||||
}
|
}
|
||||||
llmIncludeChannel, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, false)
|
llmIncludeChannel, err := s.GetBoolRuntimeSetting(RuntimeSettingLLMQueueIncludeChannel, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return runtimeSettingsSnapshot{}, err
|
return RuntimeSettingsSnapshot{}, err
|
||||||
}
|
}
|
||||||
return runtimeSettingsSnapshot{
|
return RuntimeSettingsSnapshot{
|
||||||
AllowEncryptedForwarding: allowEncrypted,
|
AllowEncryptedForwarding: allowEncrypted,
|
||||||
LLMQueueEnabled: llmQueueEnabled,
|
LLMQueueEnabled: llmQueueEnabled,
|
||||||
LLMIncludeChannel: llmIncludeChannel,
|
LLMIncludeChannel: llmIncludeChannel,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
|
func (s *Store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return false, fmt.Errorf("runtime setting key is required")
|
return false, fmt.Errorf("runtime setting key is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var row runtimeSettingRecord
|
var row RuntimeSettingRecord
|
||||||
err := s.db.Where("`key` = ?", key).Take(&row).Error
|
err := s.db.Where("`key` = ?", key).Take(&row).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return defaultValue, nil
|
return defaultValue, nil
|
||||||
@@ -68,13 +68,13 @@ func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, erro
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) SetBoolRuntimeSetting(key string, value bool, label string) (*runtimeSettingRecord, error) {
|
func (s *Store) SetBoolRuntimeSetting(key string, value bool, label string) (*RuntimeSettingRecord, error) {
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, fmt.Errorf("runtime setting key is required")
|
return nil, fmt.Errorf("runtime setting key is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
row := runtimeSettingRecord{
|
row := RuntimeSettingRecord{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: strconv.FormatBool(value),
|
Value: strconv.FormatBool(value),
|
||||||
ValueType: runtimeSettingTypeBool,
|
ValueType: runtimeSettingTypeBool,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ func TestRuntimeSettingsDefaultAndUpdates(t *testing.T) {
|
|||||||
t.Fatalf("AllowEncryptedForwarding = true, want false")
|
t.Fatalf("AllowEncryptedForwarding = true, want false")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
|
if _, err := st.SetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
|
||||||
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
|
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
|
||||||
}
|
}
|
||||||
settings, err = st.GetRuntimeSettings()
|
settings, err = st.GetRuntimeSettings()
|
||||||
@@ -25,7 +25,7 @@ func TestRuntimeSettingsDefaultAndUpdates(t *testing.T) {
|
|||||||
t.Fatalf("AllowEncryptedForwarding = false, want true")
|
t.Fatalf("AllowEncryptedForwarding = false, want true")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
|
if _, err := st.SetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
|
||||||
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
|
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
|
||||||
}
|
}
|
||||||
settings, err = st.GetRuntimeSettings()
|
settings, err = st.GetRuntimeSettings()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -6,12 +6,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *store) ListSigns(opts listOptions) ([]signRecord, error) {
|
func (s *Store) ListSigns(opts ListOptions) ([]SignRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []signRecord
|
var rows []SignRecord
|
||||||
q := applySignFilters(s.db.Model(&signRecord{}), opts).
|
q := applySignFilters(s.db.Model(&SignRecord{}), opts).
|
||||||
Order("sign_time DESC").
|
Order("sign_time DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -19,39 +21,39 @@ func (s *store) ListSigns(opts listOptions) ([]signRecord, error) {
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
type signDayCount struct {
|
type SignDayCount struct {
|
||||||
Date string `gorm:"column:sign_date"`
|
Date string `gorm:"column:sign_date"`
|
||||||
Count int64 `gorm:"column:count"`
|
Count int64 `gorm:"column:count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountSigns(opts listOptions) (int64, error) {
|
func (s *Store) CountSigns(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
q := applySignFilters(s.db.Model(&signRecord{}), opts)
|
q := applySignFilters(s.db.Model(&SignRecord{}), opts)
|
||||||
return total, q.Count(&total).Error
|
return total, q.Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountSignsByDay(opts listOptions) ([]signDayCount, error) {
|
func (s *Store) CountSignsByDay(opts ListOptions) ([]SignDayCount, error) {
|
||||||
var rows []signDayCount
|
var rows []SignDayCount
|
||||||
dateExpr := "strftime('%Y-%m-%d', sign_time)"
|
dateExpr := "strftime('%Y-%m-%d', sign_time)"
|
||||||
if s.driver == databaseDriverMySQL {
|
if s.driver == config.DriverMySQL {
|
||||||
dateExpr = "DATE_FORMAT(sign_time, '%Y-%m-%d')"
|
dateExpr = "DATE_FORMAT(sign_time, '%Y-%m-%d')"
|
||||||
}
|
}
|
||||||
q := applySignFilters(s.db.Model(&signRecord{}), opts).
|
q := applySignFilters(s.db.Model(&SignRecord{}), opts).
|
||||||
Select(dateExpr + " AS sign_date, COUNT(*) AS count").
|
Select(dateExpr + " AS sign_date, COUNT(*) AS count").
|
||||||
Group(dateExpr).
|
Group(dateExpr).
|
||||||
Order("sign_date DESC")
|
Order("sign_date DESC")
|
||||||
return rows, q.Scan(&rows).Error
|
return rows, q.Scan(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetSignByID(id uint64) (*signRecord, error) {
|
func (s *Store) GetSignByID(id uint64) (*SignRecord, error) {
|
||||||
var row signRecord
|
var row SignRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*signRecord, error) {
|
func (s *Store) CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*SignRecord, error) {
|
||||||
nodeID = strings.TrimSpace(nodeID)
|
nodeID = strings.TrimSpace(nodeID)
|
||||||
signText = strings.TrimSpace(signText)
|
signText = strings.TrimSpace(signText)
|
||||||
if nodeID == "" {
|
if nodeID == "" {
|
||||||
@@ -63,14 +65,14 @@ func (s *store) CreateSign(nodeID string, longName, shortName *string, signText
|
|||||||
if signTime.IsZero() {
|
if signTime.IsZero() {
|
||||||
signTime = time.Now()
|
signTime = time.Now()
|
||||||
}
|
}
|
||||||
row := signRecord{NodeID: nodeID, LongName: trimNullableString(longName), ShortName: trimNullableString(shortName), SignText: signText, SignTime: signTime}
|
row := SignRecord{NodeID: nodeID, LongName: trimNullableString(longName), ShortName: trimNullableString(shortName), SignText: signText, SignTime: signTime}
|
||||||
if err := s.db.Create(&row).Error; err != nil {
|
if err := s.db.Create(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateSign(id uint64, nodeID string, longName, shortName *string, signText string, signTime time.Time) (*signRecord, error) {
|
func (s *Store) UpdateSign(id uint64, nodeID string, longName, shortName *string, signText string, signTime time.Time) (*SignRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("sign id is required")
|
return nil, fmt.Errorf("sign id is required")
|
||||||
}
|
}
|
||||||
@@ -89,14 +91,14 @@ func (s *store) UpdateSign(id uint64, nodeID string, longName, shortName *string
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updates := map[string]any{"node_id": nodeID, "long_name": trimNullableString(longName), "short_name": trimNullableString(shortName), "sign_text": signText, "sign_time": signTime}
|
updates := map[string]any{"node_id": nodeID, "long_name": trimNullableString(longName), "short_name": trimNullableString(shortName), "sign_text": signText, "sign_time": signTime}
|
||||||
if err := s.db.Model(&signRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := s.db.Model(&SignRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.GetSignByID(id)
|
return s.GetSignByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteSign(id uint64) error {
|
func (s *Store) DeleteSign(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&signRecord{})
|
result := s.db.Where("id = ?", id).Delete(&SignRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,7 @@ func (s *store) DeleteSign(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applySignFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
func applySignFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||||
if opts.NodeID != "" {
|
if opts.NodeID != "" {
|
||||||
q = q.Where("node_id = ?", opts.NodeID)
|
q = q.Where("node_id = ?", opts.NodeID)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type listOptions struct {
|
type ListOptions struct {
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
NodeID string
|
NodeID string
|
||||||
@@ -21,31 +21,31 @@ type listOptions struct {
|
|||||||
MaxLng *float64
|
MaxLng *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type mapReportViewportOptions struct {
|
type MapReportViewportOptions struct {
|
||||||
ListOptions listOptions
|
ListOptions ListOptions
|
||||||
Zoom int
|
Zoom int
|
||||||
Limit int
|
Limit int
|
||||||
ClusterThreshold int
|
ClusterThreshold int
|
||||||
TargetCells int
|
TargetCells int
|
||||||
}
|
}
|
||||||
|
|
||||||
type mapReportViewportResult struct {
|
type MapReportViewportResult struct {
|
||||||
Mode string
|
Mode string
|
||||||
Total int64
|
Total int64
|
||||||
Points []mapReportRecord
|
Points []MapReportRecord
|
||||||
Clusters []mapReportClusterRecord
|
Clusters []MapReportClusterRecord
|
||||||
Limit int
|
Limit int
|
||||||
Zoom int
|
Zoom int
|
||||||
}
|
}
|
||||||
|
|
||||||
type mapReportClusterRecord struct {
|
type MapReportClusterRecord struct {
|
||||||
ClusterID string
|
ClusterID string
|
||||||
Latitude float64
|
Latitude float64
|
||||||
Longitude float64
|
Longitude float64
|
||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) Ping() error {
|
func (s *Store) Ping() error {
|
||||||
db, err := s.db.DB()
|
db, err := s.db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -53,7 +53,7 @@ func (s *store) Ping() error {
|
|||||||
return db.Ping()
|
return db.Ping()
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeListOptions(opts listOptions) listOptions {
|
func NormalizeListOptions(opts ListOptions) ListOptions {
|
||||||
if opts.Limit <= 0 {
|
if opts.Limit <= 0 {
|
||||||
opts.Limit = 100
|
opts.Limit = 100
|
||||||
}
|
}
|
||||||
@@ -66,64 +66,64 @@ func normalizeListOptions(opts listOptions) listOptions {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListNodeInfo(opts listOptions) ([]nodeInfoRecord, error) {
|
func (s *Store) ListNodeInfo(opts ListOptions) ([]NodeInfoRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []nodeInfoRecord
|
var rows []NodeInfoRecord
|
||||||
q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts).
|
q := applyNodeFilters(s.db.Model(&NodeInfoRecord{}), opts).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
Offset(opts.Offset)
|
Offset(opts.Offset)
|
||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountNodeInfo(opts listOptions) (int64, error) {
|
func (s *Store) CountNodeInfo(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts)
|
q := applyNodeFilters(s.db.Model(&NodeInfoRecord{}), opts)
|
||||||
return total, q.Count(&total).Error
|
return total, q.Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetNodeInfo(nodeID string) (*nodeInfoRecord, error) {
|
func (s *Store) GetNodeInfo(nodeID string) (*NodeInfoRecord, error) {
|
||||||
var row nodeInfoRecord
|
var row NodeInfoRecord
|
||||||
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
|
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListMapReports(opts listOptions) ([]mapReportRecord, error) {
|
func (s *Store) ListMapReports(opts ListOptions) ([]MapReportRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []mapReportRecord
|
var rows []MapReportRecord
|
||||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts).
|
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
Offset(opts.Offset)
|
Offset(opts.Offset)
|
||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountMapReports(opts listOptions) (int64, error) {
|
func (s *Store) CountMapReports(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts)
|
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts)
|
||||||
return total, q.Count(&total).Error
|
return total, q.Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetMapReport(nodeID string) (*mapReportRecord, error) {
|
func (s *Store) GetMapReport(nodeID string) (*MapReportRecord, error) {
|
||||||
var row mapReportRecord
|
var row MapReportRecord
|
||||||
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
|
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListMapReportViewport(opts mapReportViewportOptions) (*mapReportViewportResult, error) {
|
func (s *Store) ListMapReportViewport(opts MapReportViewportOptions) (*MapReportViewportResult, error) {
|
||||||
opts = normalizeMapReportViewportOptions(opts)
|
opts = NormalizeMapReportViewportOptions(opts)
|
||||||
total, err := s.CountMapReports(opts.ListOptions)
|
total, err := s.CountMapReports(opts.ListOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result := &mapReportViewportResult{Total: total, Limit: opts.Limit, Zoom: opts.Zoom}
|
result := &MapReportViewportResult{Total: total, Limit: opts.Limit, Zoom: opts.Zoom}
|
||||||
if total <= int64(opts.ClusterThreshold) {
|
if total <= int64(opts.ClusterThreshold) {
|
||||||
var points []mapReportRecord
|
var points []MapReportRecord
|
||||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions).
|
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts.ListOptions).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Limit(opts.Limit)
|
Limit(opts.Limit)
|
||||||
if err := q.Find(&points).Error; err != nil {
|
if err := q.Find(&points).Error; err != nil {
|
||||||
@@ -142,8 +142,8 @@ func (s *store) ListMapReportViewport(opts mapReportViewportOptions) (*mapReport
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapReportClusterRecord, error) {
|
func (s *Store) ListMapReportClusters(opts MapReportViewportOptions) ([]MapReportClusterRecord, error) {
|
||||||
opts = normalizeMapReportViewportOptions(opts)
|
opts = NormalizeMapReportViewportOptions(opts)
|
||||||
cellSize := mapReportClusterCellSize(opts.ListOptions, opts.TargetCells)
|
cellSize := mapReportClusterCellSize(opts.ListOptions, opts.TargetCells)
|
||||||
var rows []struct {
|
var rows []struct {
|
||||||
LatBucket int64
|
LatBucket int64
|
||||||
@@ -152,7 +152,7 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor
|
|||||||
Longitude float64
|
Longitude float64
|
||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions).
|
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts.ListOptions).
|
||||||
Select("CAST((latitude + 90.0) / ? AS INTEGER) AS lat_bucket, CAST((longitude + 180.0) / ? AS INTEGER) AS lng_bucket, AVG(latitude) AS latitude, AVG(longitude) AS longitude, COUNT(*) AS count", cellSize, cellSize).
|
Select("CAST((latitude + 90.0) / ? AS INTEGER) AS lat_bucket, CAST((longitude + 180.0) / ? AS INTEGER) AS lng_bucket, AVG(latitude) AS latitude, AVG(longitude) AS longitude, COUNT(*) AS count", cellSize, cellSize).
|
||||||
Group("lat_bucket, lng_bucket").
|
Group("lat_bucket, lng_bucket").
|
||||||
Order("count DESC").
|
Order("count DESC").
|
||||||
@@ -160,9 +160,9 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor
|
|||||||
if err := q.Scan(&rows).Error; err != nil {
|
if err := q.Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
clusters := make([]mapReportClusterRecord, 0, len(rows))
|
clusters := make([]MapReportClusterRecord, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
clusters = append(clusters, mapReportClusterRecord{
|
clusters = append(clusters, MapReportClusterRecord{
|
||||||
ClusterID: fmt.Sprintf("%d:%d", row.LatBucket, row.LngBucket),
|
ClusterID: fmt.Sprintf("%d:%d", row.LatBucket, row.LngBucket),
|
||||||
Latitude: row.Latitude,
|
Latitude: row.Latitude,
|
||||||
Longitude: row.Longitude,
|
Longitude: row.Longitude,
|
||||||
@@ -172,7 +172,7 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor
|
|||||||
return clusters, nil
|
return clusters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeMapReportViewportOptions(opts mapReportViewportOptions) mapReportViewportOptions {
|
func NormalizeMapReportViewportOptions(opts MapReportViewportOptions) MapReportViewportOptions {
|
||||||
if opts.Limit <= 0 {
|
if opts.Limit <= 0 {
|
||||||
opts.Limit = 1000
|
opts.Limit = 1000
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ func normalizeMapReportViewportOptions(opts mapReportViewportOptions) mapReportV
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapReportClusterCellSize(opts listOptions, targetCells int) float64 {
|
func mapReportClusterCellSize(opts ListOptions, targetCells int) float64 {
|
||||||
latSpan := 180.0
|
latSpan := 180.0
|
||||||
if opts.MinLat != nil && opts.MaxLat != nil {
|
if opts.MinLat != nil && opts.MaxLat != nil {
|
||||||
latSpan = *opts.MaxLat - *opts.MinLat
|
latSpan = *opts.MaxLat - *opts.MinLat
|
||||||
@@ -215,13 +215,13 @@ func mapReportClusterCellSize(opts listOptions, targetCells int) float64 {
|
|||||||
return cellSize
|
return cellSize
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteNode(nodeID string) error {
|
func (s *Store) DeleteNode(nodeID string) error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&nodeInfoRecord{})
|
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&NodeInfoRecord{})
|
||||||
if nodeResult.Error != nil {
|
if nodeResult.Error != nil {
|
||||||
return nodeResult.Error
|
return nodeResult.Error
|
||||||
}
|
}
|
||||||
reportResult := tx.Where("node_id = ?", nodeID).Delete(&mapReportRecord{})
|
reportResult := tx.Where("node_id = ?", nodeID).Delete(&MapReportRecord{})
|
||||||
if reportResult.Error != nil {
|
if reportResult.Error != nil {
|
||||||
return reportResult.Error
|
return reportResult.Error
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ func (s *store) DeleteNode(nodeID string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
func applyNodeFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||||
if opts.NodeID != "" {
|
if opts.NodeID != "" {
|
||||||
q = q.Where("node_id = ?", opts.NodeID)
|
q = q.Where("node_id = ?", opts.NodeID)
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMapReportFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
func applyMapReportFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||||
q = applyNodeFilters(q, opts)
|
q = applyNodeFilters(q, opts)
|
||||||
if opts.MinLat != nil && opts.MaxLat != nil {
|
if opts.MinLat != nil && opts.MaxLat != nil {
|
||||||
q = q.Where("latitude IS NOT NULL AND latitude >= ? AND latitude <= ?", *opts.MinLat, *opts.MaxLat)
|
q = q.Where("latitude IS NOT NULL AND latitude >= ? AND latitude <= ?", *opts.MinLat, *opts.MaxLat)
|
||||||
@@ -260,15 +260,15 @@ func applyMapReportFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) {
|
func (s *Store) ListTextMessages(opts ListOptions) ([]TextMessageRecord, error) {
|
||||||
var rows []textMessageRecord
|
var rows []TextMessageRecord
|
||||||
return rows, s.listAppendRows(opts, &rows).Error
|
return rows, s.listAppendRows(opts, &rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, error) {
|
func (s *Store) ListDiscardDetails(opts ListOptions) ([]DiscardDetailsRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
var rows []discardDetailsRecord
|
var rows []DiscardDetailsRecord
|
||||||
q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts).
|
q := applyDiscardDetailsFilters(s.db.Model(&DiscardDetailsRecord{}), opts).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Limit(opts.Limit).
|
Limit(opts.Limit).
|
||||||
@@ -276,13 +276,13 @@ func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, er
|
|||||||
return rows, q.Find(&rows).Error
|
return rows, q.Find(&rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CountDiscardDetails(opts listOptions) (int64, error) {
|
func (s *Store) CountDiscardDetails(opts ListOptions) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts)
|
q := applyDiscardDetailsFilters(s.db.Model(&DiscardDetailsRecord{}), opts)
|
||||||
return total, q.Count(&total).Error
|
return total, q.Count(&total).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDiscardDetailsFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
func applyDiscardDetailsFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||||
if opts.Since != nil {
|
if opts.Since != nil {
|
||||||
q = q.Where("created_at >= ?", *opts.Since)
|
q = q.Where("created_at >= ?", *opts.Since)
|
||||||
}
|
}
|
||||||
@@ -292,8 +292,8 @@ func applyDiscardDetailsFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) DeleteTextMessage(id uint64) error {
|
func (s *Store) DeleteTextMessage(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&textMessageRecord{})
|
result := s.db.Where("id = ?", id).Delete(&TextMessageRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -303,28 +303,28 @@ func (s *store) DeleteTextMessage(id uint64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListPositions(opts listOptions) ([]positionRecord, error) {
|
func (s *Store) ListPositions(opts ListOptions) ([]PositionRecord, error) {
|
||||||
var rows []positionRecord
|
var rows []PositionRecord
|
||||||
return rows, s.listAppendRows(opts, &rows).Error
|
return rows, s.listAppendRows(opts, &rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListTelemetry(opts listOptions) ([]telemetryRecord, error) {
|
func (s *Store) ListTelemetry(opts ListOptions) ([]TelemetryRecord, error) {
|
||||||
var rows []telemetryRecord
|
var rows []TelemetryRecord
|
||||||
return rows, s.listAppendRows(opts, &rows).Error
|
return rows, s.listAppendRows(opts, &rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListRouting(opts listOptions) ([]routingRecord, error) {
|
func (s *Store) ListRouting(opts ListOptions) ([]RoutingRecord, error) {
|
||||||
var rows []routingRecord
|
var rows []RoutingRecord
|
||||||
return rows, s.listAppendRows(opts, &rows).Error
|
return rows, s.listAppendRows(opts, &rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListTraceroute(opts listOptions) ([]tracerouteRecord, error) {
|
func (s *Store) ListTraceroute(opts ListOptions) ([]TracerouteRecord, error) {
|
||||||
var rows []tracerouteRecord
|
var rows []TracerouteRecord
|
||||||
return rows, s.listAppendRows(opts, &rows).Error
|
return rows, s.listAppendRows(opts, &rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) listAppendRows(opts listOptions, dest any) *gorm.DB {
|
func (s *Store) listAppendRows(opts ListOptions, dest any) *gorm.DB {
|
||||||
opts = normalizeListOptions(opts)
|
opts = NormalizeListOptions(opts)
|
||||||
q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
||||||
if opts.NodeID != "" {
|
if opts.NodeID != "" {
|
||||||
q = q.Where("from_id = ?", opts.NodeID)
|
q = q.Where("from_id = ?", opts.NodeID)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测试 helper —— 为从 main 包搬过来的测试提供它们原本依赖的小写函数。
|
||||||
|
// 这些 helper 不暴露给生产代码使用;它们的行为应当与 main 包对应实现保持一致。
|
||||||
|
|
||||||
|
// verifyPassword 复刻 auth.go 中的 bcrypt 校验,用于 user_store 的测试。
|
||||||
|
func verifyPassword(hash, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicMapTileSourceDTO 复刻 admin_map_source_routes.go 中的同名函数,
|
||||||
|
// 仅供 map_source_store_test.go 验证 ProxyEnabled 时 URL 是否被改写。
|
||||||
|
// 这里返回 map[string]any 而非 gin.H 以避免引入 gin 依赖。
|
||||||
|
func publicMapTileSourceDTO(row MapTileSourceRecord) map[string]any {
|
||||||
|
urlTemplate := row.URLTemplate
|
||||||
|
if row.ProxyEnabled {
|
||||||
|
hash := row.URLTemplateHash
|
||||||
|
if hash == "" {
|
||||||
|
hash = MapTileSourceHash(row.URLTemplate)
|
||||||
|
}
|
||||||
|
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": row.ID,
|
||||||
|
"name": row.Name,
|
||||||
|
"url_template": urlTemplate,
|
||||||
|
"attribution": row.Attribution,
|
||||||
|
"max_zoom": row.MaxZoom,
|
||||||
|
"enabled": row.Enabled,
|
||||||
|
"is_default": row.IsDefault,
|
||||||
|
"proxy_enabled": row.ProxyEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDBWriteQueue 是 db_write_queue_test.go 期望的旧名字。重新导出供测试使用。
|
||||||
|
var newDBWriteQueue = NewWriteQueue
|
||||||
|
|
||||||
|
// 让 strings 不会被 import-but-not-used(如果上面用不到,就算了——保留以应对将来扩展)
|
||||||
|
var _ = strings.TrimSpace
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Package testutil 提供给其它包测试使用的 store 临时实例工厂。
|
||||||
|
//
|
||||||
|
// 重构前 db_test.go 中的 openTestStore helper 被 8+ 个测试文件复用;
|
||||||
|
// 现在抽到这里,让 store 包外的测试也可以零样板地拿到一个临时 SQLite store。
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/config"
|
||||||
|
"meshtastic_mqtt_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenStore 返回一个写在 t.TempDir() 中的临时 SQLite store。
|
||||||
|
// 测试结束时调用方需要 defer st.Close()。
|
||||||
|
func OpenStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.OpenStore(config.DatabaseConfig{
|
||||||
|
Driver: config.DriverSQLite,
|
||||||
|
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "mesh_mqtt_go.db")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenStore() error = %v", err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -9,30 +9,30 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errUserAlreadyExists = errors.New("user already exists")
|
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||||
|
|
||||||
func (s *store) GetUserByUsername(username string) (*userRecord, error) {
|
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
|
||||||
var user userRecord
|
var user UserRecord
|
||||||
if err := s.db.Where("username = ?", username).Take(&user).Error; err != nil {
|
if err := s.db.Where("username = ?", username).Take(&user).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetUserByID(id uint64) (*userRecord, error) {
|
func (s *Store) GetUserByID(id uint64) (*UserRecord, error) {
|
||||||
var user userRecord
|
var user UserRecord
|
||||||
if err := s.db.Where("id = ?", id).Take(&user).Error; err != nil {
|
if err := s.db.Where("id = ?", id).Take(&user).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ListUsers() ([]userRecord, error) {
|
func (s *Store) ListUsers() ([]UserRecord, error) {
|
||||||
var users []userRecord
|
var users []UserRecord
|
||||||
return users, s.db.Order("id ASC").Find(&users).Error
|
return users, s.db.Order("id ASC").Find(&users).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) CreateAdminUser(username, password string) (*userRecord, error) {
|
func (s *Store) CreateAdminUser(username, password string) (*UserRecord, error) {
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return nil, fmt.Errorf("username is required")
|
return nil, fmt.Errorf("username is required")
|
||||||
@@ -41,7 +41,7 @@ func (s *store) CreateAdminUser(username, password string) (*userRecord, error)
|
|||||||
return nil, fmt.Errorf("password is required")
|
return nil, fmt.Errorf("password is required")
|
||||||
}
|
}
|
||||||
if _, err := s.GetUserByUsername(username); err == nil {
|
if _, err := s.GetUserByUsername(username); err == nil {
|
||||||
return nil, errUserAlreadyExists
|
return nil, ErrUserAlreadyExists
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -49,14 +49,14 @@ func (s *store) CreateAdminUser(username, password string) (*userRecord, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hash user password: %w", err)
|
return nil, fmt.Errorf("hash user password: %w", err)
|
||||||
}
|
}
|
||||||
user := userRecord{Username: username, PasswordHash: hash, Role: adminRole}
|
user := UserRecord{Username: username, PasswordHash: hash, Role: AdminRole}
|
||||||
if err := s.db.Create(&user).Error; err != nil {
|
if err := s.db.Create(&user).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, error) {
|
func (s *Store) UpdateUserPassword(id uint64, password string) (*UserRecord, error) {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("user id is required")
|
return nil, fmt.Errorf("user id is required")
|
||||||
}
|
}
|
||||||
@@ -71,15 +71,15 @@ func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hash user password: %w", err)
|
return nil, fmt.Errorf("hash user password: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.db.Model(&userRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil {
|
if err := s.db.Model(&UserRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user.PasswordHash = hash
|
user.PasswordHash = hash
|
||||||
return s.GetUserByID(id)
|
return s.GetUserByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) EnsureDefaultAdmin(username, password string) error {
|
func (s *Store) EnsureDefaultAdmin(username, password string) error {
|
||||||
var existing userRecord
|
var existing UserRecord
|
||||||
err := s.db.Where("username = ?", username).Take(&existing).Error
|
err := s.db.Where("username = ?", username).Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -91,7 +91,7 @@ func (s *store) EnsureDefaultAdmin(username, password string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hash admin password: %w", err)
|
return fmt.Errorf("hash admin password: %w", err)
|
||||||
}
|
}
|
||||||
user := userRecord{Username: username, PasswordHash: hash, Role: adminRole}
|
user := UserRecord{Username: username, PasswordHash: hash, Role: AdminRole}
|
||||||
if err := s.db.Create(&user).Error; err != nil {
|
if err := s.db.Create(&user).Error; err != nil {
|
||||||
return fmt.Errorf("create default admin user: %w", err)
|
return fmt.Errorf("create default admin user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -21,12 +23,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type mapTileProxy struct {
|
type mapTileProxy struct {
|
||||||
store *store
|
store *storepkg.Store
|
||||||
cacheDir string
|
cacheDir string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) {
|
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
|
||||||
proxy := &mapTileProxy{
|
proxy := &mapTileProxy{
|
||||||
store: store,
|
store: store,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -7,8 +7,16 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
configpkg "meshtastic_mqtt_server/internal/config"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/store/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func openTestStore(t *testing.T) *storepkg.Store {
|
||||||
|
return testutil.OpenStore(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMapTileProxyFetchesAndCaches(t *testing.T) {
|
func TestMapTileProxyFetchesAndCaches(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
@@ -24,13 +32,13 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := t.TempDir()
|
cacheDir := t.TempDir()
|
||||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
|
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
@@ -62,12 +70,12 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
|
row, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
|
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
|
||||||
@@ -89,16 +97,16 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
|
disabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
|
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
|
||||||
}
|
}
|
||||||
proxyDisabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
|
proxyDisabled, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
|
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"/api/map/not-a-hash?x=0&y=0&z=0",
|
"/api/map/not-a-hash?x=0&y=0&z=0",
|
||||||
@@ -130,16 +138,16 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
|
|
||||||
row404, err := st.CreateMapTileSource(mapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
row404, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource(404) error = %v", err)
|
t.Fatalf("CreateMapTileSource(404) error = %v", err)
|
||||||
}
|
}
|
||||||
row500, err := st.CreateMapTileSource(mapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
row500, err := st.CreateMapTileSource(storepkg.MapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateMapTileSource(500) error = %v", err)
|
t.Fatalf("CreateMapTileSource(500) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
url string
|
url string
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
mqtt "github.com/mochi-mqtt/server/v2"
|
||||||
|
|
||||||
|
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MQTTStatusProvider 是 web 层向上层要的"返回当前 mqtt broker 状态"接口;
|
||||||
|
// 实现一般由 main 包传入(持有真正的 mqtt.Server / 写队列 / 统计器)。
|
||||||
|
type MQTTStatusProvider interface {
|
||||||
|
Status() AdminMQTTStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// MQTTRuntimeStatus 把 mqtt.Server / 写队列 / 转发统计三个上下文打包成
|
||||||
|
// 实现 MQTTStatusProvider 的具体类型。供 main 包构造后注入 newRouter。
|
||||||
|
type MQTTRuntimeStatus struct {
|
||||||
|
Server *mqtt.Server
|
||||||
|
Address string
|
||||||
|
TLS bool
|
||||||
|
Stats *mqttforwardpkg.Stats
|
||||||
|
DBQueue *storepkg.WriteQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMQTTStatus 是 admin 路由 GET /admin/mqtt-status 返回的 JSON 视图。
|
||||||
|
type AdminMQTTStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
TLS bool `json:"tls"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Started int64 `json:"started"`
|
||||||
|
Uptime int64 `json:"uptime"`
|
||||||
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
|
BytesSent int64 `json:"bytes_sent"`
|
||||||
|
ClientsConnected int64 `json:"clients_connected"`
|
||||||
|
ClientsDisconnected int64 `json:"clients_disconnected"`
|
||||||
|
ClientsMaximum int64 `json:"clients_maximum"`
|
||||||
|
ClientsTotal int64 `json:"clients_total"`
|
||||||
|
MessagesReceived int64 `json:"messages_received"`
|
||||||
|
MessagesSent int64 `json:"messages_sent"`
|
||||||
|
MessagesDropped int64 `json:"messages_dropped"`
|
||||||
|
DBWriteQueueLength int `json:"db_write_queue_length"`
|
||||||
|
Retained int64 `json:"retained"`
|
||||||
|
Inflight int64 `json:"inflight"`
|
||||||
|
InflightDropped int64 `json:"inflight_dropped"`
|
||||||
|
Subscriptions int64 `json:"subscriptions"`
|
||||||
|
PacketsReceived int64 `json:"packets_received"`
|
||||||
|
PacketsSent int64 `json:"packets_sent"`
|
||||||
|
Clients []AdminMQTTClient `json:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminMQTTClient struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Listener string `json:"listener"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
RemoteHost string `json:"remote_host"`
|
||||||
|
RemotePort string `json:"remote_port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status 实现 MQTTStatusProvider。
|
||||||
|
func (m MQTTRuntimeStatus) Status() AdminMQTTStatus {
|
||||||
|
if m.Server == nil || m.Server.Info == nil {
|
||||||
|
return AdminMQTTStatus{Running: false, Address: m.Address, TLS: m.TLS, DBWriteQueueLength: m.DBQueue.Len()}
|
||||||
|
}
|
||||||
|
info := m.Server.Info.Clone()
|
||||||
|
status := AdminMQTTStatus{
|
||||||
|
Running: true,
|
||||||
|
Address: m.Address,
|
||||||
|
TLS: m.TLS,
|
||||||
|
Version: info.Version,
|
||||||
|
Started: info.Started,
|
||||||
|
Uptime: info.Uptime,
|
||||||
|
BytesReceived: info.BytesReceived,
|
||||||
|
BytesSent: info.BytesSent,
|
||||||
|
ClientsConnected: info.ClientsConnected,
|
||||||
|
ClientsDisconnected: info.ClientsDisconnected,
|
||||||
|
ClientsMaximum: info.ClientsMaximum,
|
||||||
|
ClientsTotal: info.ClientsTotal,
|
||||||
|
MessagesReceived: info.MessagesReceived,
|
||||||
|
MessagesSent: m.Stats.Forwarded(),
|
||||||
|
MessagesDropped: m.Stats.Dropped(),
|
||||||
|
DBWriteQueueLength: m.DBQueue.Len(),
|
||||||
|
Retained: info.Retained,
|
||||||
|
Inflight: info.Inflight,
|
||||||
|
InflightDropped: info.InflightDropped,
|
||||||
|
Subscriptions: info.Subscriptions,
|
||||||
|
PacketsReceived: info.PacketsReceived,
|
||||||
|
PacketsSent: info.PacketsSent,
|
||||||
|
}
|
||||||
|
for _, client := range m.Server.Clients.GetAll() {
|
||||||
|
if client == nil || client.Closed() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info := mqttClientInfo(client)
|
||||||
|
status.Clients = append(status.Clients, AdminMQTTClient{
|
||||||
|
ClientID: info.ClientID,
|
||||||
|
Username: info.Username,
|
||||||
|
Listener: info.Listener,
|
||||||
|
RemoteAddr: info.RemoteAddr,
|
||||||
|
RemoteHost: info.RemoteHost,
|
||||||
|
RemotePort: info.RemotePort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化版客户端信息——只解析展示所需字段,避免依赖 main 包里的辅助。
|
||||||
|
type mqttClientInfoView struct {
|
||||||
|
ClientID string
|
||||||
|
Username string
|
||||||
|
Listener string
|
||||||
|
RemoteAddr string
|
||||||
|
RemoteHost string
|
||||||
|
RemotePort string
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqttClientInfo(c *mqtt.Client) mqttClientInfoView {
|
||||||
|
if c == nil {
|
||||||
|
return mqttClientInfoView{}
|
||||||
|
}
|
||||||
|
info := mqttClientInfoView{
|
||||||
|
ClientID: c.ID,
|
||||||
|
Username: string(c.Properties.Username),
|
||||||
|
Listener: c.Net.Listener,
|
||||||
|
RemoteAddr: c.Net.Remote,
|
||||||
|
}
|
||||||
|
host, port := splitHostPort(c.Net.Remote)
|
||||||
|
info.RemoteHost = host
|
||||||
|
info.RemotePort = port
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPort(addr string) (string, string) {
|
||||||
|
if addr == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
// 复用 net.SplitHostPort,但要兼容 "host" 这种没端口的情况。
|
||||||
|
for i := len(addr) - 1; i >= 0; i-- {
|
||||||
|
if addr[i] == ':' {
|
||||||
|
host := addr[:i]
|
||||||
|
if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' {
|
||||||
|
host = host[1 : len(host)-1]
|
||||||
|
}
|
||||||
|
return host, addr[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addr, ""
|
||||||
|
}
|
||||||
+74
-247
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -12,16 +12,29 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/auth"
|
||||||
|
blockingpkg "meshtastic_mqtt_server/internal/blocking"
|
||||||
|
botpkg "meshtastic_mqtt_server/internal/bot"
|
||||||
|
configpkg "meshtastic_mqtt_server/internal/config"
|
||||||
|
helppkg "meshtastic_mqtt_server/internal/help"
|
||||||
|
llmadminpkg "meshtastic_mqtt_server/internal/llmadmin"
|
||||||
|
mappkg "meshtastic_mqtt_server/internal/mapsource"
|
||||||
|
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||||
|
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
||||||
|
signpkg "meshtastic_mqtt_server/internal/sign"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *http.Server {
|
func NewHTTPServer(cfg configpkg.WebConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *http.Server {
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||||
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
|
Handler: NewRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
|
func ServeUnixSocket(server *http.Server, socketPath string) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -47,7 +60,7 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
|
|||||||
return server.Serve(listener)
|
return server.Serve(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine {
|
func NewRouter(cfg configpkg.WebConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
@@ -57,7 +70,7 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir string) {
|
||||||
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 {
|
||||||
@@ -72,9 +85,9 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
|||||||
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
||||||
registerNodeInfoRoutes(r, store, "/nodes")
|
registerNodeInfoRoutes(r, store, "/nodes")
|
||||||
registerMapReportRoutes(r, store)
|
registerMapReportRoutes(r, store)
|
||||||
registerMapSourceRoutes(r, store)
|
mappkg.RegisterPublicRoutes(r, store)
|
||||||
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
||||||
registerHelpRoutes(r, store)
|
helppkg.RegisterPublicRoutes(r, store)
|
||||||
r.GET("/signs", func(c *gin.Context) {
|
r.GET("/signs", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -82,11 +95,11 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
|||||||
}
|
}
|
||||||
rows, err := store.ListSigns(opts)
|
rows, err := store.ListSigns(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, signDTO)
|
writeListResponse(c, rows, opts, err, signpkg.SignDTO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := store.CountSigns(opts)
|
total, err := store.CountSigns(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, signDTO)
|
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
|
||||||
})
|
})
|
||||||
r.GET("/signs/daily", func(c *gin.Context) {
|
r.GET("/signs/daily", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseListOptions(c)
|
||||||
@@ -94,7 +107,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.CountSignsByDay(opts)
|
rows, err := store.CountSignsByDay(opts)
|
||||||
writeListResponse(c, rows, opts, err, signDayCountDTO)
|
writeListResponse(c, rows, opts, err, signpkg.SignDayCountDTO)
|
||||||
})
|
})
|
||||||
r.GET("/text-messages", func(c *gin.Context) {
|
r.GET("/text-messages", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseListOptions(c)
|
||||||
@@ -146,7 +159,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) {
|
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) {
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -158,10 +171,10 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
type updatePasswordRequest struct {
|
type updatePasswordRequest struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
userDTO := func(user userRecord) gin.H {
|
userDTO := func(user storepkg.UserRecord) gin.H {
|
||||||
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
|
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
|
||||||
}
|
}
|
||||||
loginLogDTO := func(row loginLogRecord) gin.H {
|
loginLogDTO := func(row storepkg.LoginLogRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "username": row.Username, "user_id": ptrUint64(row.UserID), "success": row.Success, "reason": row.Reason, "remote_addr": row.RemoteAddr, "remote_host": row.RemoteHost, "user_agent": row.UserAgent, "created_at": row.CreatedAt}
|
return gin.H{"id": row.ID, "username": row.Username, "user_id": ptrUint64(row.UserID), "success": row.Success, "reason": row.Reason, "remote_addr": row.RemoteAddr, "remote_host": row.RemoteHost, "user_agent": row.UserAgent, "created_at": row.CreatedAt}
|
||||||
}
|
}
|
||||||
remoteInfo := func(c *gin.Context) (string, string) {
|
remoteInfo := func(c *gin.Context) (string, string) {
|
||||||
@@ -174,7 +187,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
}
|
}
|
||||||
recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
|
recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
|
||||||
remoteAddr, remoteHost := remoteInfo(c)
|
remoteAddr, remoteHost := remoteInfo(c)
|
||||||
_ = store.InsertLoginLog(loginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
|
_ = store.InsertLoginLog(storepkg.LoginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
|
||||||
}
|
}
|
||||||
|
|
||||||
r.POST("/login", func(c *gin.Context) {
|
r.POST("/login", func(c *gin.Context) {
|
||||||
@@ -185,46 +198,46 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := store.GetUserByUsername(req.Username)
|
user, err := store.GetUserByUsername(req.Username)
|
||||||
if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) {
|
if err != nil || user.Role != auth.AdminRole || !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
||||||
recordLogin(c, req.Username, nil, false, "invalid username or password")
|
recordLogin(c, req.Username, nil, false, "invalid username or password")
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cookie, err := sessions.newCookie(*user)
|
cookie, err := sessions.NewCookie(*user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordLogin(c, req.Username, &user.ID, true, "success")
|
recordLogin(c, req.Username, &user.ID, true, "success")
|
||||||
http.SetCookie(c.Writer, cookie)
|
http.SetCookie(c.Writer, cookie)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)})
|
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserResponse(*user)})
|
||||||
})
|
})
|
||||||
r.POST("/logout", func(c *gin.Context) {
|
r.POST("/logout", func(c *gin.Context) {
|
||||||
http.SetCookie(c.Writer, sessions.clearCookie())
|
http.SetCookie(c.Writer, sessions.ClearCookie())
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
protected := r.Group("")
|
protected := r.Group("")
|
||||||
protected.Use(requireAdmin(sessions))
|
protected.Use(auth.RequireAdmin(sessions))
|
||||||
registerAdminBlockingRoutes(protected, store, blocking)
|
blockingpkg.RegisterRoutes(protected, store, blocking)
|
||||||
registerAdminSignRoutes(protected, store)
|
signpkg.RegisterAdminRoutes(protected, store)
|
||||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
mqttforwardpkg.RegisterRoutes(protected, store, forwarder)
|
||||||
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
rspkg.RegisterRoutes(protected, store, settings)
|
||||||
registerAdminMapSourceRoutes(protected, store)
|
mappkg.RegisterAdminRoutes(protected, store)
|
||||||
registerAdminHelpRoutes(protected, store)
|
helppkg.RegisterAdminRoutes(protected, store)
|
||||||
registerAdminBotRoutes(protected, store, botSender)
|
botpkg.RegisterRoutes(protected, store, botSender)
|
||||||
registerAdminLLMRoutes(protected, store)
|
llmadminpkg.RegisterRoutes(protected, store)
|
||||||
protected.GET("/me", func(c *gin.Context) {
|
protected.GET("/me", func(c *gin.Context) {
|
||||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||||
})
|
})
|
||||||
protected.GET("/mqtt/status", func(c *gin.Context) {
|
protected.GET("/mqtt/status", func(c *gin.Context) {
|
||||||
if mqttStatus == nil {
|
if mqttStatus == nil {
|
||||||
c.JSON(http.StatusOK, adminMqttStatus{Running: false})
|
c.JSON(http.StatusOK, AdminMQTTStatus{Running: false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := mqttStatus.Status()
|
status := mqttStatus.Status()
|
||||||
discardCount, err := store.CountDiscardDetails(listOptions{})
|
discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -251,7 +264,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := store.CreateAdminUser(req.Username, req.Password)
|
user, err := store.CreateAdminUser(req.Username, req.Password)
|
||||||
if errors.Is(err, errUserAlreadyExists) {
|
if errors.Is(err, storepkg.ErrUserAlreadyExists) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -323,7 +336,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) {
|
||||||
r.GET(path, func(c *gin.Context) {
|
r.GET(path, func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -351,7 +364,7 @@ func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerMapReportRoutes(r gin.IRouter, store *store) {
|
func registerMapReportRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||||
r.GET("/map-reports/viewport", func(c *gin.Context) {
|
r.GET("/map-reports/viewport", func(c *gin.Context) {
|
||||||
opts, ok := parseMapReportViewportOptions(c)
|
opts, ok := parseMapReportViewportOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -431,226 +444,69 @@ func serveIndex(c *gin.Context, staticDir string) {
|
|||||||
c.File(indexPath)
|
c.File(indexPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseListOptions(c *gin.Context) (listOptions, bool) {
|
func parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
|
||||||
limit, ok := parseIntQuery(c, "limit", 100)
|
return webutil.ParseListOptions(c)
|
||||||
if !ok {
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
offset, ok := parseIntQuery(c, "offset", 0)
|
|
||||||
if !ok {
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
nodeID := c.Query("node_id")
|
|
||||||
if nodeID == "" {
|
|
||||||
nodeID = c.Query("from")
|
|
||||||
}
|
|
||||||
channelID := c.Query("channel_id")
|
|
||||||
var since, until *time.Time
|
|
||||||
if value := c.Query("since"); value != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"})
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
since = &parsed
|
|
||||||
}
|
|
||||||
if value := c.Query("until"); value != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"})
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
until = &parsed
|
|
||||||
}
|
|
||||||
return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseMapReportListOptions(c *gin.Context) (listOptions, bool) {
|
func parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
|
||||||
opts, ok := parseListOptions(c)
|
return webutil.ParseMapReportListOptions(c)
|
||||||
if !ok {
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
minLat, hasMinLat, ok := parseOptionalFloatQuery(c, "min_lat")
|
|
||||||
if !ok {
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
maxLat, hasMaxLat, ok := parseOptionalFloatQuery(c, "max_lat")
|
|
||||||
if !ok {
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
minLng, hasMinLng, ok := parseOptionalFloatQuery(c, "min_lng")
|
|
||||||
if !ok {
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
maxLng, hasMaxLng, ok := parseOptionalFloatQuery(c, "max_lng")
|
|
||||||
if !ok {
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
boundsCount := 0
|
|
||||||
for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} {
|
|
||||||
if present {
|
|
||||||
boundsCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if boundsCount == 0 {
|
|
||||||
return opts, true
|
|
||||||
}
|
|
||||||
if boundsCount != 4 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"})
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"})
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
if minLat > maxLat {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"})
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"})
|
|
||||||
return listOptions{}, false
|
|
||||||
}
|
|
||||||
opts.MinLat = &minLat
|
|
||||||
opts.MaxLat = &maxLat
|
|
||||||
opts.MinLng = &minLng
|
|
||||||
opts.MaxLng = &maxLng
|
|
||||||
return opts, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseMapReportViewportOptions(c *gin.Context) (mapReportViewportOptions, bool) {
|
func parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) {
|
||||||
opts, ok := parseMapReportListOptions(c)
|
return webutil.ParseMapReportViewportOptions(c)
|
||||||
if !ok {
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"})
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
zoom, ok := parseIntQuery(c, "zoom", 0)
|
|
||||||
if !ok {
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
if zoom < 0 || zoom > 24 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"})
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
limit, ok := parseIntQuery(c, "limit", 1000)
|
|
||||||
if !ok {
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
clusterThreshold, ok := parseIntQuery(c, "cluster_threshold", 500)
|
|
||||||
if !ok {
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
targetCells, ok := parseIntQuery(c, "target_cells", 64)
|
|
||||||
if !ok {
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"})
|
|
||||||
return mapReportViewportOptions{}, false
|
|
||||||
}
|
|
||||||
return normalizeMapReportViewportOptions(mapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) {
|
|
||||||
value := c.Query(name)
|
|
||||||
if value == "" {
|
|
||||||
return 0, false, true
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseFloat(value, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
|
||||||
return 0, true, false
|
|
||||||
}
|
|
||||||
return parsed, true, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
||||||
value := c.Query(name)
|
return webutil.ParseIntQuery(c, name, defaultValue)
|
||||||
if value == "" {
|
|
||||||
return defaultValue, true
|
|
||||||
}
|
|
||||||
parsed, err := strconv.Atoi(value)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return parsed, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeListResponse[T any](c *gin.Context, rows []T, opts listOptions, err error, convert func(T) gin.H) {
|
func writeListResponse[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, err error, convert func(T) gin.H) {
|
||||||
if err != nil {
|
webutil.WriteListResponse(c, rows, opts, err, convert)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]gin.H, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, convert(row))
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOptions, total int64, err error, convert func(T) gin.H) {
|
func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, total int64, err error, convert func(T) gin.H) {
|
||||||
if err != nil {
|
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]gin.H, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, convert(row))
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func nodeInfoDTO(row nodeInfoRecord) gin.H {
|
func nodeInfoDTO(row storepkg.NodeInfoRecord) gin.H {
|
||||||
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "user_id": ptrString(row.UserID), "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "is_licensed": ptrBool(row.IsLicensed), "public_key": ptrString(row.PublicKey), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "user_id": ptrString(row.UserID), "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "is_licensed": ptrBool(row.IsLicensed), "public_key": ptrString(row.PublicKey), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapReportDTO(row mapReportRecord) gin.H {
|
func mapReportDTO(row storepkg.MapReportRecord) gin.H {
|
||||||
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapReportViewportPointDTO(row mapReportRecord) gin.H {
|
func mapReportViewportPointDTO(row storepkg.MapReportRecord) gin.H {
|
||||||
item := mapReportDTO(row)
|
item := mapReportDTO(row)
|
||||||
item["type"] = "point"
|
item["type"] = "point"
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapReportClusterDTO(row mapReportClusterRecord) gin.H {
|
func mapReportClusterDTO(row storepkg.MapReportClusterRecord) gin.H {
|
||||||
return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
|
return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
|
||||||
}
|
}
|
||||||
|
|
||||||
func signDTO(row signRecord) gin.H {
|
func textMessageDTO(row storepkg.TextMessageRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime}
|
|
||||||
}
|
|
||||||
|
|
||||||
func signDayCountDTO(row signDayCount) gin.H {
|
|
||||||
return gin.H{"date": row.Date, "count": row.Count}
|
|
||||||
}
|
|
||||||
|
|
||||||
func textMessageDTO(row 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}
|
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}
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardDetailsDTO(row discardDetailsRecord) gin.H {
|
func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func positionDTO(row positionRecord) gin.H {
|
func positionDTO(row storepkg.PositionRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func telemetryDTO(row telemetryRecord) gin.H {
|
func telemetryDTO(row storepkg.TelemetryRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "telemetry_type": ptrString(row.TelemetryType), "metrics_json": ptrString(row.MetricsJSON), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "telemetry_type": ptrString(row.TelemetryType), "metrics_json": ptrString(row.MetricsJSON), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func routingDTO(row routingRecord) gin.H {
|
func routingDTO(row storepkg.RoutingRecord) gin.H {
|
||||||
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tracerouteDTO(row tracerouteRecord) gin.H {
|
func tracerouteDTO(row storepkg.TracerouteRecord) gin.H {
|
||||||
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,37 +514,8 @@ func appendPacketDTO(id uint64, fromID string, fromNum int64, packetID *int64, p
|
|||||||
return gin.H{"id": id, "from_id": fromID, "from_num": fromNum, "packet_id": ptrInt64(packetID), "portnum": ptrString(portnum), "created_at": createdAt, "content_json": contentJSON}
|
return gin.H{"id": id, "from_id": fromID, "from_num": fromNum, "packet_id": ptrInt64(packetID), "portnum": ptrString(portnum), "created_at": createdAt, "content_json": contentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptrString(value *string) any {
|
func ptrString(value *string) any { return webutil.PtrString(value) }
|
||||||
if value == nil {
|
func ptrInt64(value *int64) any { return webutil.PtrInt64(value) }
|
||||||
return nil
|
func ptrUint64(value *uint64) any { return webutil.PtrUint64(value) }
|
||||||
}
|
func ptrFloat64(value *float64) any { return webutil.PtrFloat64(value) }
|
||||||
return *value
|
func ptrBool(value *bool) any { return webutil.PtrBool(value) }
|
||||||
}
|
|
||||||
|
|
||||||
func ptrInt64(value *int64) any {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrUint64(value *uint64) any {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrFloat64(value *float64) any {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrBool(value *bool) any {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Package webutil 收集 admin/路由层共享的 HTTP 解析与响应工具。
|
||||||
|
//
|
||||||
|
// 这些函数原本散落在 web.go 中(parseListOptions、writeListResponse、
|
||||||
|
// parseMapReportListOptions 等),任何注册 admin 路由的领域包都依赖它们。
|
||||||
|
// 把它们抽离出来可以避免 internal/web 同时被 internal/blocking、
|
||||||
|
// internal/bot 等包反向引用造成循环依赖。
|
||||||
|
package webutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseListOptions 从请求中读取 limit / offset / since / until / node_id /
|
||||||
|
// channel_id 等通用过滤参数;解析失败时它会写入 400 响应并返回 false。
|
||||||
|
func ParseListOptions(c *gin.Context) (store.ListOptions, bool) {
|
||||||
|
limit, ok := ParseIntQuery(c, "limit", 100)
|
||||||
|
if !ok {
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
offset, ok := ParseIntQuery(c, "offset", 0)
|
||||||
|
if !ok {
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
nodeID := c.Query("node_id")
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = c.Query("from")
|
||||||
|
}
|
||||||
|
channelID := c.Query("channel_id")
|
||||||
|
var since, until *time.Time
|
||||||
|
if value := c.Query("since"); value != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"})
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
since = &parsed
|
||||||
|
}
|
||||||
|
if value := c.Query("until"); value != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"})
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
until = &parsed
|
||||||
|
}
|
||||||
|
return store.NormalizeListOptions(store.ListOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMapReportListOptions 在 ParseListOptions 的基础上解析 4 个地图边界。
|
||||||
|
func ParseMapReportListOptions(c *gin.Context) (store.ListOptions, bool) {
|
||||||
|
opts, ok := ParseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
minLat, hasMinLat, ok := ParseOptionalFloatQuery(c, "min_lat")
|
||||||
|
if !ok {
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
maxLat, hasMaxLat, ok := ParseOptionalFloatQuery(c, "max_lat")
|
||||||
|
if !ok {
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
minLng, hasMinLng, ok := ParseOptionalFloatQuery(c, "min_lng")
|
||||||
|
if !ok {
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
maxLng, hasMaxLng, ok := ParseOptionalFloatQuery(c, "max_lng")
|
||||||
|
if !ok {
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
boundsCount := 0
|
||||||
|
for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} {
|
||||||
|
if present {
|
||||||
|
boundsCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if boundsCount == 0 {
|
||||||
|
return opts, true
|
||||||
|
}
|
||||||
|
if boundsCount != 4 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"})
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"})
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
if minLat > maxLat {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"})
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"})
|
||||||
|
return store.ListOptions{}, false
|
||||||
|
}
|
||||||
|
opts.MinLat = &minLat
|
||||||
|
opts.MaxLat = &maxLat
|
||||||
|
opts.MinLng = &minLng
|
||||||
|
opts.MaxLng = &maxLng
|
||||||
|
return opts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMapReportViewportOptions 在 ParseMapReportListOptions 之上解析 zoom /
|
||||||
|
// cluster_threshold / target_cells 等额外字段。
|
||||||
|
func ParseMapReportViewportOptions(c *gin.Context) (store.MapReportViewportOptions, bool) {
|
||||||
|
opts, ok := ParseMapReportListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"})
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
zoom, ok := ParseIntQuery(c, "zoom", 0)
|
||||||
|
if !ok {
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
if zoom < 0 || zoom > 24 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"})
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
limit, ok := ParseIntQuery(c, "limit", 1000)
|
||||||
|
if !ok {
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
clusterThreshold, ok := ParseIntQuery(c, "cluster_threshold", 500)
|
||||||
|
if !ok {
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
targetCells, ok := ParseIntQuery(c, "target_cells", 64)
|
||||||
|
if !ok {
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"})
|
||||||
|
return store.MapReportViewportOptions{}, false
|
||||||
|
}
|
||||||
|
return store.NormalizeMapReportViewportOptions(store.MapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIntQuery 从请求查询字符串解析整数;缺省时返回 defaultValue。
|
||||||
|
func ParseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
||||||
|
value := c.Query(name)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue, true
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOptionalFloatQuery 解析可选浮点查询参数;返回 (value, present, ok)。
|
||||||
|
func ParseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) {
|
||||||
|
value := c.Query(name)
|
||||||
|
if value == "" {
|
||||||
|
return 0, false, true
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||||
|
return 0, true, false
|
||||||
|
}
|
||||||
|
return parsed, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, convert(row))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。
|
||||||
|
func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) {
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, convert(row))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any,
|
||||||
|
// 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。
|
||||||
|
func PtrString(value *string) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func PtrInt64(value *int64) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func PtrUint64(value *uint64) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func PtrFloat64(value *float64) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func PtrBool(value *bool) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
@@ -14,15 +14,23 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"meshtastic_mqtt_server/ai"
|
|
||||||
"meshtastic_mqtt_server/autoreply"
|
|
||||||
"meshtastic_mqtt_server/llm"
|
|
||||||
"meshtastic_mqtt_server/mqtpp"
|
|
||||||
|
|
||||||
mqtt "github.com/mochi-mqtt/server/v2"
|
mqtt "github.com/mochi-mqtt/server/v2"
|
||||||
"github.com/mochi-mqtt/server/v2/hooks/auth"
|
mqttauth "github.com/mochi-mqtt/server/v2/hooks/auth"
|
||||||
"github.com/mochi-mqtt/server/v2/listeners"
|
"github.com/mochi-mqtt/server/v2/listeners"
|
||||||
"github.com/mochi-mqtt/server/v2/packets"
|
"github.com/mochi-mqtt/server/v2/packets"
|
||||||
|
|
||||||
|
"meshtastic_mqtt_server/ai"
|
||||||
|
"meshtastic_mqtt_server/autoreply"
|
||||||
|
"meshtastic_mqtt_server/internal/auth"
|
||||||
|
blockingpkg "meshtastic_mqtt_server/internal/blocking"
|
||||||
|
botpkg "meshtastic_mqtt_server/internal/bot"
|
||||||
|
configpkg "meshtastic_mqtt_server/internal/config"
|
||||||
|
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||||
|
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
webpkg "meshtastic_mqtt_server/internal/web"
|
||||||
|
"meshtastic_mqtt_server/llm"
|
||||||
|
"meshtastic_mqtt_server/mqtpp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -39,10 +47,10 @@ const (
|
|||||||
type meshtasticFilterHook struct {
|
type meshtasticFilterHook struct {
|
||||||
mqtt.HookBase
|
mqtt.HookBase
|
||||||
key []byte
|
key []byte
|
||||||
dbQueue *dbWriteQueue
|
dbQueue *storepkg.WriteQueue
|
||||||
stats *meshtasticMessageStats
|
stats *mqttforwardpkg.Stats
|
||||||
blocking *blockingCache
|
blocking *blockingpkg.Cache
|
||||||
settings *runtimeSettingsCache
|
settings *rspkg.Cache
|
||||||
pkiResolver func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool)
|
pkiResolver func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool)
|
||||||
autoAcker func(record map[string]any)
|
autoAcker func(record map[string]any)
|
||||||
}
|
}
|
||||||
@@ -107,7 +115,7 @@ func (h *meshtasticFilterHook) rejectPublish(cl *mqtt.Client, pk packets.Packet,
|
|||||||
h.dbQueue.EnqueueDiscard(record, pk.Payload, mqttClientInfoFromClient(cl))
|
h.dbQueue.EnqueueDiscard(record, pk.Payload, mqttClientInfoFromClient(cl))
|
||||||
}
|
}
|
||||||
|
|
||||||
func blockingViolationForRecord(blocking *blockingCache, record map[string]any) map[string]any {
|
func blockingViolationForRecord(blocking *blockingpkg.Cache, record map[string]any) map[string]any {
|
||||||
if blocking == nil || record == nil {
|
if blocking == nil || record == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -129,12 +137,12 @@ func blockingViolationForRecord(blocking *blockingCache, record map[string]any)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mqttClientInfoFromClient(cl *mqtt.Client) mqttClientInfo {
|
func mqttClientInfoFromClient(cl *mqtt.Client) storepkg.MQTTClientInfo {
|
||||||
if cl == nil {
|
if cl == nil {
|
||||||
return mqttClientInfo{}
|
return storepkg.MQTTClientInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
info := mqttClientInfo{
|
info := storepkg.MQTTClientInfo{
|
||||||
ClientID: cl.ID,
|
ClientID: cl.ID,
|
||||||
Username: string(cl.Properties.Username),
|
Username: string(cl.Properties.Username),
|
||||||
Listener: cl.Net.Listener,
|
Listener: cl.Net.Listener,
|
||||||
@@ -166,8 +174,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseArgs 加载配置文件、解析命令行覆盖项,并展开 Meshtastic channel PSK。
|
// parseArgs 加载配置文件、解析命令行覆盖项,并展开 Meshtastic channel PSK。
|
||||||
func parseArgs() (*config, error) {
|
func parseArgs() (*configpkg.Config, error) {
|
||||||
cfg, err := loadConfig(defaultConfigPath())
|
cfg, err := configpkg.Load(configpkg.DefaultPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -198,52 +206,52 @@ func parseArgs() (*config, error) {
|
|||||||
if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" {
|
if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" {
|
||||||
cfg.Web.Admin.SessionSecret = value
|
cfg.Web.Admin.SessionSecret = value
|
||||||
}
|
}
|
||||||
clearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS)
|
configpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS)
|
||||||
|
|
||||||
if err := validateConfig(cfg); err != nil {
|
if err := configpkg.Validate(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
key, err := mqtpp.ExpandPSK(cfg.Meshtastic.PSK)
|
key, err := mqtpp.ExpandPSK(cfg.Meshtastic.PSK)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cfg.key = key
|
cfg.Key = key
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// run 创建 MQTT broker 和 Web 服务,并阻塞等待退出信号。
|
// run 创建 MQTT broker 和 Web 服务,并阻塞等待退出信号。
|
||||||
func run(cfg *config) error {
|
func run(cfg *configpkg.Config) error {
|
||||||
store, err := openStore(cfg.Database)
|
store, err := storepkg.OpenStore(cfg.Database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
dbQueue := newDBWriteQueue(store)
|
dbQueue := storepkg.NewWriteQueue(store)
|
||||||
defer dbQueue.Close()
|
defer dbQueue.Close()
|
||||||
if err := store.EnsureDefaultAdmin(cfg.Web.Admin.Username, cfg.Web.Admin.Password); err != nil {
|
if err := store.EnsureDefaultAdmin(cfg.Web.Admin.Username, cfg.Web.Admin.Password); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
blocking, err := newBlockingCache(store)
|
blocking, err := blockingpkg.New(store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
settings, err := newRuntimeSettingsCache(store)
|
settings, err := rspkg.New(store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
messageStats := &meshtasticMessageStats{}
|
messageStats := &mqttforwardpkg.Stats{}
|
||||||
server, mqttHook, mqttAddr, err := startMQTTServer(cfg, store, dbQueue, messageStats, blocking, settings)
|
server, mqttHook, mqttAddr, err := startMQTTServer(cfg, store, dbQueue, messageStats, blocking, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
botSender := newBotService(store, server, cfg.key)
|
botSender := botpkg.NewService(store, server, cfg.Key)
|
||||||
mqttHook.autoAcker = botSender.MaybeAutoAck
|
mqttHook.autoAcker = botSender.MaybeAutoAck
|
||||||
botCtx, stopBotBroadcaster := context.WithCancel(context.Background())
|
botCtx, stopBotBroadcaster := context.WithCancel(context.Background())
|
||||||
defer stopBotBroadcaster()
|
defer stopBotBroadcaster()
|
||||||
botSender.StartNodeInfoBroadcaster(botCtx)
|
botSender.StartNodeInfoBroadcaster(botCtx)
|
||||||
forwardManager := newMQTTForwardManager(store)
|
forwardManager := mqttforwardpkg.NewManager(store)
|
||||||
if err := forwardManager.StartFromStore(); err != nil {
|
if err := forwardManager.StartFromStore(); err != nil {
|
||||||
server.Close()
|
server.Close()
|
||||||
return err
|
return err
|
||||||
@@ -262,12 +270,12 @@ func run(cfg *config) error {
|
|||||||
providerConfigs := make([]llm.ProviderConfig, 0, len(llmProviders))
|
providerConfigs := make([]llm.ProviderConfig, 0, len(llmProviders))
|
||||||
for _, p := range llmProviders {
|
for _, p := range llmProviders {
|
||||||
providerConfigs = append(providerConfigs, llm.ProviderConfig{
|
providerConfigs = append(providerConfigs, llm.ProviderConfig{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Active: p.Active,
|
Active: p.Active,
|
||||||
APIKey: p.APIKey,
|
APIKey: p.APIKey,
|
||||||
BaseURL: p.BaseURL,
|
BaseURL: p.BaseURL,
|
||||||
Model: p.Model,
|
Model: p.Model,
|
||||||
Timeout: p.Timeout,
|
Timeout: p.Timeout,
|
||||||
ContextWindowTokens: p.ContextWindowTokens,
|
ContextWindowTokens: p.ContextWindowTokens,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -276,7 +284,7 @@ func run(cfg *config) error {
|
|||||||
botSenderAdapter := autoreply.NewBotServiceAdapter(
|
botSenderAdapter := autoreply.NewBotServiceAdapter(
|
||||||
// SendDirectText: 发送私聊消息
|
// SendDirectText: 发送私聊消息
|
||||||
func(ctx context.Context, botID uint64, toNodeNum int64, text string) error {
|
func(ctx context.Context, botID uint64, toNodeNum int64, text string) error {
|
||||||
_, err := botSender.SendText(ctx, botSendTextRequest{
|
_, err := botSender.SendText(ctx, botpkg.SendTextRequest{
|
||||||
BotID: botID,
|
BotID: botID,
|
||||||
MessageType: "direct",
|
MessageType: "direct",
|
||||||
ToNodeNum: &toNodeNum,
|
ToNodeNum: &toNodeNum,
|
||||||
@@ -286,7 +294,7 @@ func run(cfg *config) error {
|
|||||||
},
|
},
|
||||||
// SendChannelText: 发送频道消息
|
// SendChannelText: 发送频道消息
|
||||||
func(ctx context.Context, botID uint64, channelID string, text string) error {
|
func(ctx context.Context, botID uint64, channelID string, text string) error {
|
||||||
_, err := botSender.SendText(ctx, botSendTextRequest{
|
_, err := botSender.SendText(ctx, botpkg.SendTextRequest{
|
||||||
BotID: botID,
|
BotID: botID,
|
||||||
MessageType: "channel",
|
MessageType: "channel",
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
@@ -297,11 +305,11 @@ func run(cfg *config) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
aiService, err = ai.NewService(ai.Config{
|
aiService, err = ai.NewService(ai.Config{
|
||||||
LLMProviders: providerConfigs,
|
LLMProviders: providerConfigs,
|
||||||
DataDir: cfg.DataDir,
|
DataDir: cfg.DataDir,
|
||||||
Enabled: cfg.AI.Enabled,
|
Enabled: cfg.AI.Enabled,
|
||||||
ToolConfigStore: store,
|
ToolConfigStore: store,
|
||||||
}, store.db, botSenderAdapter)
|
}, store.DB(), botSenderAdapter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -319,12 +327,12 @@ func run(cfg *config) error {
|
|||||||
var httpServers []*http.Server
|
var httpServers []*http.Server
|
||||||
errCh := make(chan error, 2)
|
errCh := make(chan error, 2)
|
||||||
if cfg.Web.Enabled {
|
if cfg.Web.Enabled {
|
||||||
sessions, err := newSessionManager(cfg.Web.Admin)
|
sessions, err := auth.NewManager(cfg.Web.Admin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, DBQueue: dbQueue}
|
||||||
handler := newRouter(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
handler := webpkg.NewRouter(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
||||||
webAddresses := []string{}
|
webAddresses := []string{}
|
||||||
if cfg.Web.PortEnabled {
|
if cfg.Web.PortEnabled {
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
@@ -344,7 +352,7 @@ func run(cfg *config) error {
|
|||||||
httpServers = append(httpServers, httpServer)
|
httpServers = append(httpServers, httpServer)
|
||||||
webAddresses = append(webAddresses, cfg.Web.SocketPath)
|
webAddresses = append(webAddresses, cfg.Web.SocketPath)
|
||||||
go func() {
|
go func() {
|
||||||
if err := serveHTTPUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := webpkg.ServeUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -378,25 +386,25 @@ func run(cfg *config) error {
|
|||||||
return runErr
|
return runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func startMQTTServer(cfg *config, store *store, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, *meshtasticFilterHook, string, error) {
|
func startMQTTServer(cfg *configpkg.Config, store *storepkg.Store, dbQueue *storepkg.WriteQueue, stats *mqttforwardpkg.Stats, blocking *blockingpkg.Cache, settings *rspkg.Cache) (*mqtt.Server, *meshtasticFilterHook, string, error) {
|
||||||
server := mqtt.New(&mqtt.Options{InlineClient: true})
|
server := mqtt.New(&mqtt.Options{InlineClient: true})
|
||||||
if err := server.AddHook(new(auth.AllowHook), nil); err != nil {
|
if err := server.AddHook(new(mqttauth.AllowHook), nil); err != nil {
|
||||||
return nil, nil, "", err
|
return nil, nil, "", err
|
||||||
}
|
}
|
||||||
hook := &meshtasticFilterHook{
|
hook := &meshtasticFilterHook{
|
||||||
key: cfg.key,
|
key: cfg.Key,
|
||||||
dbQueue: dbQueue,
|
dbQueue: dbQueue,
|
||||||
stats: stats,
|
stats: stats,
|
||||||
blocking: blocking,
|
blocking: blocking,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
pkiResolver: newPKIKeyResolver(store),
|
pkiResolver: botpkg.NewPKIKeyResolver(store),
|
||||||
}
|
}
|
||||||
if err := server.AddHook(hook, nil); err != nil {
|
if err := server.AddHook(hook, nil); err != nil {
|
||||||
return nil, nil, "", err
|
return nil, nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := net.JoinHostPort(cfg.MQTT.Host, strconv.Itoa(cfg.MQTT.Port))
|
addr := net.JoinHostPort(cfg.MQTT.Host, strconv.Itoa(cfg.MQTT.Port))
|
||||||
tlsConfig, err := buildTLSConfig(cfg.MQTT.TLS)
|
tlsConfig, err := configpkg.BuildTLS(cfg.MQTT.TLS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, "", err
|
return nil, nil, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-6
@@ -4,11 +4,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mqtt "github.com/mochi-mqtt/server/v2"
|
mqtt "github.com/mochi-mqtt/server/v2"
|
||||||
|
|
||||||
|
blockingpkg "meshtastic_mqtt_server/internal/blocking"
|
||||||
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
|
"meshtastic_mqtt_server/internal/store/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMQTTClientInfoFromClientNil(t *testing.T) {
|
func TestMQTTClientInfoFromClientNil(t *testing.T) {
|
||||||
info := mqttClientInfoFromClient(nil)
|
info := mqttClientInfoFromClient(nil)
|
||||||
if info != (mqttClientInfo{}) {
|
if info != (storepkg.MQTTClientInfo{}) {
|
||||||
t.Fatalf("info = %#v, want zero value", info)
|
t.Fatalf("info = %#v, want zero value", info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,10 +46,21 @@ func TestMQTTClientInfoFromClientUnsplitRemote(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlockingViolationForRecordNode(t *testing.T) {
|
// blockingViolationForRecord 的测试用真实 *Store + blocking.Cache 走完整路径,
|
||||||
cache := &blockingCache{nodes: map[string]struct{}{"!12345678": {}}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}}
|
// 不依赖 cache 的未导出字段。
|
||||||
record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)}
|
|
||||||
|
|
||||||
|
func TestBlockingViolationForRecordNode(t *testing.T) {
|
||||||
|
st := testutil.OpenStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
nodeNum := int64(305419896)
|
||||||
|
if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "blocked", true); err != nil {
|
||||||
|
t.Fatalf("CreateNodeBlocking() error = %v", err)
|
||||||
|
}
|
||||||
|
cache, err := blockingpkg.New(st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blocking.New() error = %v", err)
|
||||||
|
}
|
||||||
|
record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)}
|
||||||
violation := blockingViolationForRecord(cache, record)
|
violation := blockingViolationForRecord(cache, record)
|
||||||
if violation == nil || violation["blocking_type"] != "node" {
|
if violation == nil || violation["blocking_type"] != "node" {
|
||||||
t.Fatalf("blockingViolationForRecord() = %#v, want node violation", violation)
|
t.Fatalf("blockingViolationForRecord() = %#v, want node violation", violation)
|
||||||
@@ -53,7 +68,15 @@ func TestBlockingViolationForRecordNode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) {
|
func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) {
|
||||||
cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}}
|
st := testutil.OpenStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil {
|
||||||
|
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
|
||||||
|
}
|
||||||
|
cache, err := blockingpkg.New(st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blocking.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -74,7 +97,15 @@ func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBlockingViolationForRecordAllowed(t *testing.T) {
|
func TestBlockingViolationForRecordAllowed(t *testing.T) {
|
||||||
cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}}
|
st := testutil.OpenStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "blocked", true); err != nil {
|
||||||
|
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
|
||||||
|
}
|
||||||
|
cache, err := blockingpkg.New(st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blocking.New() error = %v", err)
|
||||||
|
}
|
||||||
record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"}
|
record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"}
|
||||||
if violation := blockingViolationForRecord(cache, record); violation != nil {
|
if violation := blockingViolationForRecord(cache, record); violation != nil {
|
||||||
t.Fatalf("blockingViolationForRecord() = %#v, want nil", violation)
|
t.Fatalf("blockingViolationForRecord() = %#v, want nil", violation)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
mqtt "github.com/mochi-mqtt/server/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mqttStatusProvider interface {
|
|
||||||
Status() adminMqttStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
type mqttRuntimeStatus struct {
|
|
||||||
server *mqtt.Server
|
|
||||||
address string
|
|
||||||
tls bool
|
|
||||||
stats *meshtasticMessageStats
|
|
||||||
dbQueue *dbWriteQueue
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminMqttStatus struct {
|
|
||||||
Running bool `json:"running"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
TLS bool `json:"tls"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Started int64 `json:"started"`
|
|
||||||
Uptime int64 `json:"uptime"`
|
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
|
||||||
BytesSent int64 `json:"bytes_sent"`
|
|
||||||
ClientsConnected int64 `json:"clients_connected"`
|
|
||||||
ClientsDisconnected int64 `json:"clients_disconnected"`
|
|
||||||
ClientsMaximum int64 `json:"clients_maximum"`
|
|
||||||
ClientsTotal int64 `json:"clients_total"`
|
|
||||||
MessagesReceived int64 `json:"messages_received"`
|
|
||||||
MessagesSent int64 `json:"messages_sent"`
|
|
||||||
MessagesDropped int64 `json:"messages_dropped"`
|
|
||||||
DBWriteQueueLength int `json:"db_write_queue_length"`
|
|
||||||
Retained int64 `json:"retained"`
|
|
||||||
Inflight int64 `json:"inflight"`
|
|
||||||
InflightDropped int64 `json:"inflight_dropped"`
|
|
||||||
Subscriptions int64 `json:"subscriptions"`
|
|
||||||
PacketsReceived int64 `json:"packets_received"`
|
|
||||||
PacketsSent int64 `json:"packets_sent"`
|
|
||||||
Clients []adminMqttClient `json:"clients"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminMqttClient struct {
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Listener string `json:"listener"`
|
|
||||||
RemoteAddr string `json:"remote_addr"`
|
|
||||||
RemoteHost string `json:"remote_host"`
|
|
||||||
RemotePort string `json:"remote_port"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mqttRuntimeStatus) Status() adminMqttStatus {
|
|
||||||
if m.server == nil || m.server.Info == nil {
|
|
||||||
return adminMqttStatus{Running: false, Address: m.address, TLS: m.tls, DBWriteQueueLength: m.dbQueue.Len()}
|
|
||||||
}
|
|
||||||
info := m.server.Info.Clone()
|
|
||||||
status := adminMqttStatus{
|
|
||||||
Running: true,
|
|
||||||
Address: m.address,
|
|
||||||
TLS: m.tls,
|
|
||||||
Version: info.Version,
|
|
||||||
Started: info.Started,
|
|
||||||
Uptime: info.Uptime,
|
|
||||||
BytesReceived: info.BytesReceived,
|
|
||||||
BytesSent: info.BytesSent,
|
|
||||||
ClientsConnected: info.ClientsConnected,
|
|
||||||
ClientsDisconnected: info.ClientsDisconnected,
|
|
||||||
ClientsMaximum: info.ClientsMaximum,
|
|
||||||
ClientsTotal: info.ClientsTotal,
|
|
||||||
MessagesReceived: info.MessagesReceived,
|
|
||||||
MessagesSent: m.stats.Forwarded(),
|
|
||||||
MessagesDropped: m.stats.Dropped(),
|
|
||||||
DBWriteQueueLength: m.dbQueue.Len(),
|
|
||||||
Retained: info.Retained,
|
|
||||||
Inflight: info.Inflight,
|
|
||||||
InflightDropped: info.InflightDropped,
|
|
||||||
Subscriptions: info.Subscriptions,
|
|
||||||
PacketsReceived: info.PacketsReceived,
|
|
||||||
PacketsSent: info.PacketsSent,
|
|
||||||
}
|
|
||||||
for _, client := range m.server.Clients.GetAll() {
|
|
||||||
if client == nil || client.Closed() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
clientInfo := mqttClientInfoFromClient(client)
|
|
||||||
status.Clients = append(status.Clients, adminMqttClient{
|
|
||||||
ClientID: clientInfo.ClientID,
|
|
||||||
Username: clientInfo.Username,
|
|
||||||
Listener: clientInfo.Listener,
|
|
||||||
RemoteAddr: clientInfo.RemoteAddr,
|
|
||||||
RemoteHost: clientInfo.RemoteHost,
|
|
||||||
RemotePort: clientInfo.RemotePort,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type runtimeSettingsCache struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
settings runtimeSettingsSnapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRuntimeSettingsCache(store *store) (*runtimeSettingsCache, error) {
|
|
||||||
cache := &runtimeSettingsCache{}
|
|
||||||
if err := cache.Reload(store); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cache, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *runtimeSettingsCache) Reload(store *store) error {
|
|
||||||
if store == nil {
|
|
||||||
return fmt.Errorf("store is required")
|
|
||||||
}
|
|
||||||
settings, err := store.GetRuntimeSettings()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
c.settings = settings
|
|
||||||
c.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *runtimeSettingsCache) Snapshot() runtimeSettingsSnapshot {
|
|
||||||
if c == nil {
|
|
||||||
return runtimeSettingsSnapshot{}
|
|
||||||
}
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
return c.settings
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *runtimeSettingsCache) AllowEncryptedForwarding() bool {
|
|
||||||
return c.Snapshot().AllowEncryptedForwarding
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user