diff --git a/auth.go b/auth.go deleted file mode 100644 index f946e78..0000000 --- a/auth.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -// 桥接到 internal/auth — 让根目录其余文件继续用旧的小写名(sessionManager、 -// sessionClaims、newSessionManager、requireAdmin、verifyPassword 等)。 - -import ( - "github.com/gin-gonic/gin" - - authpkg "meshtastic_mqtt_server/internal/auth" -) - -// 类型别名 -type ( - sessionManager = authpkg.Manager - sessionClaims = authpkg.SessionClaims - adminUserDTO = authpkg.AdminUserDTO -) - -const adminRole = authpkg.AdminRole - -func newSessionManager(cfg webAdminConfig) (*sessionManager, error) { - return authpkg.NewManager(cfg) -} - -func verifyPassword(hash, password string) bool { return authpkg.VerifyPassword(hash, password) } - -func adminUserResponse(user userRecord) adminUserDTO { return authpkg.AdminUserResponse(user) } - -func requireAdmin(sm *sessionManager) gin.HandlerFunc { return authpkg.RequireAdmin(sm) } diff --git a/bot_bridge.go b/bot_bridge.go new file mode 100644 index 0000000..61924d8 --- /dev/null +++ b/bot_bridge.go @@ -0,0 +1,31 @@ +package main + +// 桥接到 internal/bot — 让 main.go / web.go 中使用 botService / +// botTextSender / newBotService / newPKIKeyResolver / registerAdminBotRoutes +// 这些旧名字的代码继续可用。 + +import ( + mqtt "github.com/mochi-mqtt/server/v2" + + "github.com/gin-gonic/gin" + + botpkg "meshtastic_mqtt_server/internal/bot" +) + +type ( + botService = botpkg.Service + botTextSender = botpkg.TextSender + botSendTextRequest = botpkg.SendTextRequest +) + +func newBotService(s *store, server *mqtt.Server, key []byte) *botService { + return botpkg.NewService(s, server, key) +} + +func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { + return botpkg.NewPKIKeyResolver(s) +} + +func registerAdminBotRoutes(r gin.IRouter, s *store, sender botTextSender) { + botpkg.RegisterRoutes(r, s, sender) +} diff --git a/admin_bot_routes.go b/internal/bot/admin_bot_routes.go similarity index 81% rename from admin_bot_routes.go rename to internal/bot/admin_bot_routes.go index ddeb306..8b59e31 100644 --- a/admin_bot_routes.go +++ b/internal/bot/admin_bot_routes.go @@ -1,4 +1,4 @@ -package main +package bot import ( "errors" @@ -7,6 +7,10 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/auth" + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type botNodeRequest struct { @@ -32,19 +36,19 @@ type botSendMessageRequest struct { 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) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListBotNodes(opts) if err != nil { - writeListResponse(c, rows, opts, err, botNodeDTO) + webutil.WriteListResponse(c, rows, opts, err, botNodeDTO) return } 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) { var req botNodeRequest @@ -125,14 +129,14 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { } rows, err := store.ListBotMessages(opts) if err != nil { - writeListResponse(c, rows, opts.ListOptions, err, botMessageDTO) + webutil.WriteListResponse(c, rows, opts.ListOptions, err, botMessageDTO) return } 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) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { 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"}) 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) if err != nil { - writeListResponse(c, rows, opts, err, botDirectMessageDTO) + webutil.WriteListResponse(c, rows, opts, err, botDirectMessageDTO) return } total, err := store.CountBotDirectMessagesByConversation(dmOpts) - writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO) + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO) }) // /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数), // 给前端侧边栏渲染会话列表使用。 r.GET("/bot/conversations", func(c *gin.Context) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { 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"}) return } - claims := c.MustGet("admin_claims").(*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}) + claims := c.MustGet("admin_claims").(*auth.SessionClaims) + 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) { c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) return @@ -254,8 +258,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { }) } -func botNodeInputFromRequest(req botNodeRequest) 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} +func botNodeInputFromRequest(req botNodeRequest) storepkg.BotNodeInput { + 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) { @@ -267,25 +271,25 @@ func parseBotID(c *gin.Context, message string) (uint64, bool) { return id, true } -func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) { - listOpts, ok := parseListOptions(c) +func parseBotMessageListOptions(c *gin.Context) (storepkg.BotMessageListOptions, bool) { + listOpts, ok := webutil.ParseListOptions(c) 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 != "" { id, err := strconv.ParseUint(value, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) - return botMessageListOptions{}, false + return storepkg.BotMessageListOptions{}, false } opts.BotID = id } return opts, true } -func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord, err error) { - if errors.Is(err, errBotNodeAlreadyExists) { +func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotNodeRecord, err error) { + if errors.Is(err, storepkg.ErrBotNodeAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"}) return } @@ -300,15 +304,15 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord 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} } -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} } -func botDirectMessageDTO(row botDirectMessageRecord) gin.H { +func botDirectMessageDTO(row storepkg.BotDirectMessageRecord) gin.H { return gin.H{ "id": row.ID, "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{ "bot_id": row.BotID, "peer_node_id": row.PeerNodeID, diff --git a/bot_pki_resolver.go b/internal/bot/bot_pki_resolver.go similarity index 78% rename from bot_pki_resolver.go rename to internal/bot/bot_pki_resolver.go index ed4dcf1..2831516 100644 --- a/bot_pki_resolver.go +++ b/internal/bot/bot_pki_resolver.go @@ -1,14 +1,16 @@ -package main +package bot import ( "encoding/base64" "strings" + + storepkg "meshtastic_mqtt_server/internal/store" ) -// newPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者 +// NewPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者 // 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。 // 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。 -func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { +func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) { if s == nil { return nil } diff --git a/bot_service.go b/internal/bot/bot_service.go similarity index 81% rename from bot_service.go rename to internal/bot/bot_service.go index 9315809..39f05f8 100644 --- a/bot_service.go +++ b/internal/bot/bot_service.go @@ -1,4 +1,4 @@ -package main +package bot import ( "context" @@ -12,15 +12,16 @@ import ( "time" "unicode/utf8" - "meshtastic_mqtt_server/mqtpp" - mqtt "github.com/mochi-mqtt/server/v2" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/mqtpp" ) const botMaxTextBytes = 200 -type botSendTextRequest struct { +type SendTextRequest struct { BotID uint64 MessageType string ChannelID string @@ -30,22 +31,22 @@ type botSendTextRequest struct { CreatedBy string } -type botTextSender interface { - SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error) - PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) +type TextSender interface { + SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error) + PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error) } -type botService struct { - store *store +type Service struct { + store *storepkg.Store server *mqtt.Server key []byte } -func newBotService(store *store, server *mqtt.Server, key []byte) *botService { - return &botService{store: store, server: server, key: key} +func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service { + 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 { return } @@ -59,7 +60,7 @@ func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) { // - 其它情况用原 channel + bot PSK 加密 // // 解析失败、目标不是受管 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 { 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) if privateKeyB64 == "" { return nil, fmt.Errorf("bot has no private key") @@ -119,7 +120,7 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request if err != nil { return nil, err } - senderPublic, err := decodeBotPublicKey(*bot) + senderPublic, err := storepkg.DecodeBotPublicKey(*bot) if err != nil { return nil, err } @@ -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) if channel == "" || channel == mqtpp.PKIChannelID { return nil, fmt.Errorf("no channel id available for psk ack") } psk := strings.TrimSpace(bot.PSK) if psk == "" { - psk = botDefaultPSK + psk = storepkg.BotDefaultPSK } key, err := mqtpp.ExpandPSK(psk) if err != nil { @@ -208,7 +209,7 @@ func errString(err error) string { 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 { 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) // 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) } @@ -257,7 +258,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe } psk := strings.TrimSpace(bot.PSK) if psk == "" { - psk = botDefaultPSK + psk = storepkg.BotDefaultPSK } key, err := mqtpp.ExpandPSK(psk) if err != nil { @@ -280,7 +281,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe return nil, err } topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID) - row := &botMessageRecord{ + row := &storepkg.BotMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, @@ -293,7 +294,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe Text: text, PayloadLen: int64(len(raw)), Encrypted: true, - Status: botMessageStatusPending, + Status: storepkg.BotMessageStatusPending, CreatedBy: strings.TrimSpace(req.CreatedBy), } return s.persistAndPublish(row, topic, raw) @@ -303,7 +304,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe // - 从 nodeinfo 中查目标节点的 X25519 公钥 // - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密 // - 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 { 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 { return nil, fmt.Errorf("invalid bot private key: %w", err) } - senderPublic, err := decodeBotPublicKey(*bot) + senderPublic, err := storepkg.DecodeBotPublicKey(*bot) if err != nil { return nil, err } @@ -339,11 +340,11 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui return nil, err } topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID) - row := &botMessageRecord{ + row := &storepkg.BotMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, - MessageType: botMessageTypeDirect, + MessageType: storepkg.BotMessageTypeDirect, ChannelID: mqtpp.PKIChannelID, ToNodeID: toNodeID, ToNodeNum: int64PtrOrNil(int64(toNodeNum), true), @@ -352,7 +353,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui Text: text, PayloadLen: int64(len(raw)), Encrypted: true, - Status: botMessageStatusPending, + Status: storepkg.BotMessageStatusPending, CreatedBy: strings.TrimSpace(createdBy), } 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。失败仅打日志。 -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 { return } status := msg.Status if status == "" { if sendErr != nil { - status = botMessageStatusFailed + status = storepkg.BotMessageStatusFailed } else { - status = botMessageStatusPublished + status = storepkg.BotMessageStatusPublished } } errText := msg.Error @@ -396,13 +397,13 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes botMessageID = &id } now := time.Now() - dm := &botDirectMessageRecord{ + dm := &storepkg.BotDirectMessageRecord{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, PeerNodeID: peerNodeID, PeerNodeNum: int64(peerNodeNum), - Direction: botDirectMessageDirectionOutbound, + Direction: storepkg.BotDirectMessageDirectionOutbound, Topic: msg.Topic, PacketID: msg.PacketID, Text: text, @@ -430,7 +431,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes } // 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) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -458,33 +459,33 @@ func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) { } // 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 { return nil, err } if s.server == nil { - _ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil) - row.Status = botMessageStatusFailed + _ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil) + row.Status = storepkg.BotMessageStatusFailed row.Error = "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 { - _ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil) - row.Status = botMessageStatusFailed + _ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, err.Error(), nil) + row.Status = storepkg.BotMessageStatusFailed row.Error = err.Error() return row, err } 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 } - row.Status = botMessageStatusPublished + row.Status = storepkg.BotMessageStatusPublished row.Error = "" row.PublishedAt = &now return row, nil } -func (s *botService) runNodeInfoBroadcaster(ctx context.Context) { +func (s *Service) runNodeInfoBroadcaster(ctx context.Context) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() s.broadcastDueNodeInfo(ctx) @@ -498,8 +499,8 @@ func (s *botService) runNodeInfoBroadcaster(ctx context.Context) { } } -func (s *botService) broadcastDueNodeInfo(ctx context.Context) { - rows, err := s.store.ListBotNodes(listOptions{Limit: 500}) +func (s *Service) broadcastDueNodeInfo(ctx context.Context) { + rows, err := s.store.ListBotNodes(storepkg.ListOptions{Limit: 500}) if err != nil { printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()}) return @@ -514,7 +515,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) { } interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second 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 { 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 { 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 } -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 { 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) if psk == "" { - psk = botDefaultPSK + psk = storepkg.BotDefaultPSK } key, err := mqtpp.ExpandPSK(psk) if err != nil { @@ -569,7 +570,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error if err != nil { return err } - publicKey, err := decodeBotPublicKey(bot) + publicKey, err := storepkg.DecodeBotPublicKey(bot) if err != nil { return err } @@ -612,21 +613,21 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error func normalizeBotMessageType(value string) (string, error) { switch strings.TrimSpace(value) { - case "", botMessageTypeChannel: - return botMessageTypeChannel, nil - case botMessageTypeDirect: - return botMessageTypeDirect, nil + case "", storepkg.BotMessageTypeChannel: + return storepkg.BotMessageTypeChannel, nil + case storepkg.BotMessageTypeDirect: + return storepkg.BotMessageTypeDirect, nil default: return "", fmt.Errorf("message type must be channel or direct") } } -func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) { - if messageType == botMessageTypeChannel { +func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) { + if messageType == storepkg.BotMessageTypeChannel { return int64(mqtpp.NodeNumBroadcast), nil, nil } 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 } nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum)) @@ -640,7 +641,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin if err != nil { return 0, nil, err } - if err := validateBotNodeNum(int64(nodeNum)); err != nil { + if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil { return 0, nil, err } normalized := mqtpp.NodeNumToID(nodeNum) @@ -650,7 +651,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin func botMQTTTopic(topicPrefix, channelID, nodeID string) string { prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/") if prefix == "" { - prefix = botDefaultTopicPrefix + prefix = storepkg.BotDefaultTopicPrefix } if strings.HasSuffix(prefix, "/2/e") { return prefix + "/" + channelID + "/" + nodeID diff --git a/internal/bot/helpers.go b/internal/bot/helpers.go new file mode 100644 index 0000000..f0553c8 --- /dev/null +++ b/internal/bot/helpers.go @@ -0,0 +1,8 @@ +package bot + +// printJSON 是 bot service 的内部诊断输出钩子;当前为 noop,与重构前 +// main.go 中的 printJSON 行为一致(注释掉了实际写出)。 +// 如需调试可直接替换实现。 +func printJSON(record map[string]any) { + _ = record +} diff --git a/admin_llm_routes.go b/internal/llmadmin/admin_llm_routes.go similarity index 89% rename from admin_llm_routes.go rename to internal/llmadmin/admin_llm_routes.go index a86a7e7..cd5d973 100644 --- a/admin_llm_routes.go +++ b/internal/llmadmin/admin_llm_routes.go @@ -1,4 +1,4 @@ -package main +package llmadmin import ( "errors" @@ -8,9 +8,12 @@ import ( "github.com/gin-gonic/gin" "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") { // 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) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } @@ -65,7 +68,7 @@ func handleListLLMMessages(store *store) gin.HandlerFunc { items := make([]map[string]any, 0, len(rows)) for _, row := range rows { - items = append(items, llmMessageDTO(row)) + items = append(items, storepkg.LLMMessageDTO(row)) } 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) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -95,11 +98,11 @@ func handleGetLLMMessage(store *store) gin.HandlerFunc { 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) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { @@ -118,10 +121,10 @@ func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc { // 验证状态值 validStatus := map[string]bool{ - llmMessageStatusPending: true, - llmMessageStatusProcessing: true, - llmMessageStatusProcessed: true, - llmMessageStatusError: true, + storepkg.LLMMessageStatusPending: true, + storepkg.LLMMessageStatusProcessing: true, + storepkg.LLMMessageStatusProcessed: true, + storepkg.LLMMessageStatusError: true, } if !validStatus[req.Status] { 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) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) 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) { botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64) 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) { var req struct { Days int `json:"days"` @@ -206,7 +209,7 @@ func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc { // LLM Provider Handlers // ============================================ -func handleListLLMProviders(store *store) gin.HandlerFunc { +func handleListLLMProviders(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { 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) { name := c.Param("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) { var req struct { Name string `json:"name"` @@ -268,7 +271,7 @@ func handleCreateLLMProvider(store *store) gin.HandlerFunc { return } - record := &llmProviderRecord{ + record := &storepkg.LLMProviderRecord{ Name: req.Name, Active: req.Active, 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) { name := c.Param("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) { name := c.Param("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{ "name": row.Name, "active": row.Active, @@ -386,7 +389,7 @@ func llmProviderDTO(row llmProviderRecord) map[string]any { // LLM Tool Router Handlers // ============================================ -func handleGetLLMToolRouter(store *store) gin.HandlerFunc { +func handleGetLLMToolRouter(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { record, err := store.GetLLMToolRouter() 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) { record, err := store.GetLLMToolRouter() if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -446,7 +449,7 @@ func handleUpdateLLMToolRouter(store *store) gin.HandlerFunc { if record == nil { // 创建新配置 - newRecord := &llmToolRouterRecord{ + newRecord := &storepkg.LLMToolRouterRecord{ Enabled: req.Enabled != nil && *req.Enabled, OpenAIName: "", 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{ "id": row.ID, "enabled": row.Enabled, @@ -504,7 +507,7 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any { // LLM Primary Config Handlers // ============================================ -func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc { +func handleGetLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc { return func(c *gin.Context) { record, err := store.GetLLMPrimaryConfig() 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) { record, err := store.GetLLMPrimaryConfig() if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -568,7 +571,7 @@ func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc { if record == nil { // 创建新配置 - newRecord := &llmPrimaryConfigRecord{ + newRecord := &storepkg.LLMPrimaryConfigRecord{ Enabled: req.Enabled != nil && *req.Enabled, ProviderName: "", 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{ "id": row.ID, "enabled": row.Enabled, diff --git a/admin_map_source_routes.go b/internal/mapsource/admin_map_source_routes.go similarity index 71% rename from admin_map_source_routes.go rename to internal/mapsource/admin_map_source_routes.go index 43172ff..e06fe2e 100644 --- a/admin_map_source_routes.go +++ b/internal/mapsource/admin_map_source_routes.go @@ -1,4 +1,5 @@ -package main +// Package mapsource 提供地图瓦片源的 admin 与公开路由。 +package mapsource import ( "errors" @@ -7,6 +8,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type mapTileSourceRequest struct { @@ -19,14 +23,15 @@ type mapTileSourceRequest struct { 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) { row, err := store.GetDefaultMapTileSource() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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) { rows, err := store.ListEnabledMapTileSources() @@ -36,25 +41,26 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) { } items := make([]gin.H, 0, len(rows)) for _, row := range rows { - items = append(items, publicMapTileSourceDTO(row)) + items = append(items, PublicDTO(row)) } 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) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListMapTileSources(opts) if err != nil { - writeListResponse(c, rows, opts, err, mapTileSourceDTO) + webutil.WriteListResponse(c, rows, opts, err, AdminDTO) return } 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) { var req mapTileSourceRequest @@ -95,8 +101,8 @@ func registerAdminMapSourceRoutes(r gin.IRouter, store *store) { }) } -func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput { - return mapTileSourceInput{ +func mapTileSourceInputFromRequest(req mapTileSourceRequest) storepkg.MapTileSourceInput { + return storepkg.MapTileSourceInput{ Name: req.Name, URLTemplate: req.URLTemplate, Attribution: req.Attribution, @@ -116,8 +122,8 @@ func parseMapTileSourceID(c *gin.Context) (uint64, bool) { return id, true } -func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) { - if errors.Is(err, errMapTileSourceAlreadyExists) { +func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *storepkg.MapTileSourceRecord, err error) { + if errors.Is(err, storepkg.ErrMapTileSourceAlreadyExists) { c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"}) 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"}) 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()}) return } @@ -133,7 +139,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)}) + c.JSON(status, gin.H{"item": AdminDTO(*row)}) } 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"}) return } - if errors.Is(err, errMapTileSourceCannotDeleteDefault) { + if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -152,16 +158,19 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) { 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} } -func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H { +// PublicDTO 是给前端用户使用的视图:当 ProxyEnabled 为 true 时,url 改写为 +// 通过本服务的 /api/map/{hash} 代理路径,避免暴露上游瓦片地址。 +func PublicDTO(row storepkg.MapTileSourceRecord) gin.H { urlTemplate := row.URLTemplate if row.ProxyEnabled { hash := row.URLTemplateHash if hash == "" { - hash = mapTileSourceHash(row.URLTemplate) + hash = storepkg.MapTileSourceHash(row.URLTemplate) } urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}" } diff --git a/admin_sign_routes.go b/internal/sign/admin_sign_routes.go similarity index 62% rename from admin_sign_routes.go rename to internal/sign/admin_sign_routes.go index 76b089e..4dbd963 100644 --- a/admin_sign_routes.go +++ b/internal/sign/admin_sign_routes.go @@ -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 ( "errors" @@ -8,6 +12,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" + "meshtastic_mqtt_server/internal/webutil" ) type signRequest struct { @@ -18,19 +25,20 @@ type signRequest struct { 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) { - opts, ok := parseListOptions(c) + opts, ok := webutil.ParseListOptions(c) if !ok { return } rows, err := store.ListSigns(opts) if err != nil { - writeListResponse(c, rows, opts, err, signDTO) + webutil.WriteListResponse(c, rows, opts, err, SignDTO) return } 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) { var req signRequest @@ -42,7 +50,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) { if !ok { 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) }) r.PUT("/signs/:id", func(c *gin.Context) { @@ -59,7 +67,7 @@ func registerAdminSignRoutes(r gin.IRouter, store *store) { if !ok { 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) }) 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 } -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) { c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"}) return @@ -110,5 +118,15 @@ func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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} } diff --git a/map_tile_proxy_routes.go b/internal/web/map_tile_proxy_routes.go similarity index 96% rename from map_tile_proxy_routes.go rename to internal/web/map_tile_proxy_routes.go index d8fee9d..b0bb947 100644 --- a/map_tile_proxy_routes.go +++ b/internal/web/map_tile_proxy_routes.go @@ -1,4 +1,4 @@ -package main +package web import ( "errors" @@ -13,6 +13,8 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + + storepkg "meshtastic_mqtt_server/internal/store" ) const ( @@ -21,12 +23,12 @@ const ( ) type mapTileProxy struct { - store *store + store *storepkg.Store cacheDir string client *http.Client } -func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) { +func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) { proxy := &mapTileProxy{ store: store, cacheDir: cacheDir, diff --git a/map_tile_proxy_routes_test.go b/internal/web/map_tile_proxy_routes_test.go similarity index 69% rename from map_tile_proxy_routes_test.go rename to internal/web/map_tile_proxy_routes_test.go index 130d814..4dca24b 100644 --- a/map_tile_proxy_routes_test.go +++ b/internal/web/map_tile_proxy_routes_test.go @@ -1,4 +1,4 @@ -package main +package web import ( "net/http" @@ -7,8 +7,16 @@ import ( "path/filepath" "strings" "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) { st := openTestStore(t) defer st.Close() @@ -24,13 +32,13 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) { })) 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 { t.Fatalf("CreateMapTileSource() error = %v", err) } 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" for i := 0; i < 2; i++ { @@ -62,12 +70,12 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) { st := openTestStore(t) 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 { 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{ "/api/map/" + row.URLTemplateHash + "?y=0&z=0", @@ -89,16 +97,16 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) { st := openTestStore(t) 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 { 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 { 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{ "/api/map/not-a-hash?x=0&y=0&z=0", @@ -130,16 +138,16 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) { })) 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 { 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 { 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 { url string diff --git a/internal/web/mqtt_status.go b/internal/web/mqtt_status.go new file mode 100644 index 0000000..e3e3eeb --- /dev/null +++ b/internal/web/mqtt_status.go @@ -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, "" +} diff --git a/web.go b/internal/web/web.go similarity index 61% rename from web.go rename to internal/web/web.go index 0161535..50272ea 100644 --- a/web.go +++ b/internal/web/web.go @@ -1,4 +1,4 @@ -package main +package web import ( "errors" @@ -12,16 +12,29 @@ import ( "github.com/gin-gonic/gin" "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{ 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 { return err } @@ -47,7 +60,7 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error { 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.Use(gin.Logger(), gin.Recovery()) api := r.Group("/api") @@ -57,7 +70,7 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus 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) { status := gin.H{"status": "ok", "database": "ok"} 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, "/nodes") registerMapReportRoutes(r, store) - registerMapSourceRoutes(r, store) + mappkg.RegisterPublicRoutes(r, store) registerMapTileProxyRoutes(r, store, mapTileCacheDir) - registerHelpRoutes(r, store) + helppkg.RegisterPublicRoutes(r, store) r.GET("/signs", func(c *gin.Context) { opts, ok := parseListOptions(c) if !ok { @@ -82,11 +95,11 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { } rows, err := store.ListSigns(opts) if err != nil { - writeListResponse(c, rows, opts, err, signDTO) + writeListResponse(c, rows, opts, err, signpkg.SignDTO) return } 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) { opts, ok := parseListOptions(c) @@ -94,7 +107,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { return } 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) { 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 { Username string `json:"username"` Password string `json:"password"` @@ -158,10 +171,10 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, type updatePasswordRequest struct { 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} } - 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} } 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) { 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) { @@ -185,7 +198,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, return } 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") c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return @@ -197,7 +210,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, } recordLogin(c, req.Username, &user.ID, true, "success") 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) { http.SetCookie(c.Writer, sessions.ClearCookie()) @@ -205,26 +218,26 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, }) protected := r.Group("") - protected.Use(requireAdmin(sessions)) - registerAdminBlockingRoutes(protected, store, blocking) - registerAdminSignRoutes(protected, store) - registerAdminMQTTForwardRoutes(protected, store, forwarder) - registerAdminRuntimeSettingsRoutes(protected, store, settings) - registerAdminMapSourceRoutes(protected, store) - registerAdminHelpRoutes(protected, store) - registerAdminBotRoutes(protected, store, botSender) - registerAdminLLMRoutes(protected, store) + protected.Use(auth.RequireAdmin(sessions)) + blockingpkg.RegisterRoutes(protected, store, blocking) + signpkg.RegisterAdminRoutes(protected, store) + mqttforwardpkg.RegisterRoutes(protected, store, forwarder) + rspkg.RegisterRoutes(protected, store, settings) + mappkg.RegisterAdminRoutes(protected, store) + helppkg.RegisterAdminRoutes(protected, store) + botpkg.RegisterRoutes(protected, store, botSender) + llmadminpkg.RegisterRoutes(protected, store) protected.GET("/me", func(c *gin.Context) { - claims := c.MustGet("admin_claims").(*sessionClaims) - c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}}) + claims := c.MustGet("admin_claims").(*auth.SessionClaims) + c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}}) }) protected.GET("/mqtt/status", func(c *gin.Context) { if mqttStatus == nil { - c.JSON(http.StatusOK, adminMqttStatus{Running: false}) + c.JSON(http.StatusOK, AdminMQTTStatus{Running: false}) return } status := mqttStatus.Status() - discardCount, err := store.CountDiscardDetails(listOptions{}) + discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -251,7 +264,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, return } 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"}) 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) { opts, ok := parseListOptions(c) 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) { opts, ok := parseMapReportViewportOptions(c) if !ok { @@ -431,226 +444,69 @@ func serveIndex(c *gin.Context, staticDir string) { c.File(indexPath) } -func parseListOptions(c *gin.Context) (listOptions, bool) { - limit, ok := parseIntQuery(c, "limit", 100) - 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 parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) { + return webutil.ParseListOptions(c) } -func parseMapReportListOptions(c *gin.Context) (listOptions, bool) { - opts, ok := parseListOptions(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 parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) { + return webutil.ParseMapReportListOptions(c) } -func parseMapReportViewportOptions(c *gin.Context) (mapReportViewportOptions, bool) { - opts, ok := parseMapReportListOptions(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 parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) { + return webutil.ParseMapReportViewportOptions(c) } 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 + return webutil.ParseIntQuery(c, name, defaultValue) } -func writeListResponse[T any](c *gin.Context, rows []T, opts 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}) +func writeListResponse[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, err error, convert func(T) gin.H) { + webutil.WriteListResponse(c, rows, opts, err, convert) } -func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts 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}) +func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, total int64, err error, convert func(T) gin.H) { + webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert) } -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} } -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} } -func mapReportViewportPointDTO(row mapReportRecord) gin.H { +func mapReportViewportPointDTO(row storepkg.MapReportRecord) gin.H { item := mapReportDTO(row) item["type"] = "point" 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} } -func signDTO(row signRecord) 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 { +func textMessageDTO(row storepkg.TextMessageRecord) gin.H { return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON} } -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} } -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} } -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} } -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) } -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) } @@ -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} } -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 -} +func ptrString(value *string) any { return webutil.PtrString(value) } +func ptrInt64(value *int64) any { return webutil.PtrInt64(value) } +func ptrUint64(value *uint64) any { return webutil.PtrUint64(value) } +func ptrFloat64(value *float64) any { return webutil.PtrFloat64(value) } +func ptrBool(value *bool) any { return webutil.PtrBool(value) } diff --git a/llmadmin_bridge.go b/llmadmin_bridge.go new file mode 100644 index 0000000..287d0c4 --- /dev/null +++ b/llmadmin_bridge.go @@ -0,0 +1,13 @@ +package main + +// 桥接到 internal/llmadmin — 让 web.go 中的 registerAdminLLMRoutes 旧名仍可用。 + +import ( + "github.com/gin-gonic/gin" + + llmadminpkg "meshtastic_mqtt_server/internal/llmadmin" +) + +func registerAdminLLMRoutes(r *gin.RouterGroup, s *store) { + llmadminpkg.RegisterRoutes(r, s) +} diff --git a/mapsource_bridge.go b/mapsource_bridge.go new file mode 100644 index 0000000..772e6ad --- /dev/null +++ b/mapsource_bridge.go @@ -0,0 +1,13 @@ +package main + +// 桥接到 internal/mapsource — 让 web.go 中的 registerMapSourceRoutes / +// registerAdminMapSourceRoutes 旧名字仍可用。 + +import ( + "github.com/gin-gonic/gin" + + mspkg "meshtastic_mqtt_server/internal/mapsource" +) + +func registerMapSourceRoutes(r gin.IRouter, s *store) { mspkg.RegisterPublicRoutes(r, s) } +func registerAdminMapSourceRoutes(r gin.IRouter, s *store) { mspkg.RegisterAdminRoutes(r, s) } diff --git a/mqtt_status.go b/mqtt_status.go deleted file mode 100644 index 4b3a53c..0000000 --- a/mqtt_status.go +++ /dev/null @@ -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 -} diff --git a/sign_bridge.go b/sign_bridge.go new file mode 100644 index 0000000..1a13ecb --- /dev/null +++ b/sign_bridge.go @@ -0,0 +1,17 @@ +package main + +// 桥接到 internal/sign — 让 web.go 中 registerAdminSignRoutes / signDTO / +// signDayCountDTO 旧名字仍可用。 + +import ( + "github.com/gin-gonic/gin" + + signpkg "meshtastic_mqtt_server/internal/sign" +) + +func registerAdminSignRoutes(r gin.IRouter, s *store) { + signpkg.RegisterAdminRoutes(r, s) +} + +func signDTO(row signRecord) gin.H { return signpkg.SignDTO(row) } +func signDayCountDTO(row signDayCount) gin.H { return signpkg.SignDayCountDTO(row) } diff --git a/web_bridge.go b/web_bridge.go new file mode 100644 index 0000000..ddd57f0 --- /dev/null +++ b/web_bridge.go @@ -0,0 +1,56 @@ +package main + +// 桥接到 internal/web — 让 main.go 中使用 newSessionManager / newRouter / +// mqttRuntimeStatus / serveHTTPUnixSocket 这些旧名字的代码继续编译。 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + mqtt "github.com/mochi-mqtt/server/v2" + + "meshtastic_mqtt_server/internal/auth" + mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" + storepkg "meshtastic_mqtt_server/internal/store" + webpkg "meshtastic_mqtt_server/internal/web" +) + +// 旧类型/旧函数名 → 新位置的别名。 + +type mqttRuntimeStatusInternal = webpkg.MQTTRuntimeStatus + +// mqttRuntimeStatus 旧名字保持小写、字段也是小写——这里用一个适配类型把 +// main 包的旧字段写法包到 web 包导出的大写字段上。 +type mqttRuntimeStatus struct { + server *mqtt.Server + address string + tls bool + stats *meshtasticMessageStats + dbQueue *dbWriteQueue +} + +// 让 mqttRuntimeStatus 自动实现 webpkg.MQTTStatusProvider,把请求转给真正的实现。 +func (m mqttRuntimeStatus) Status() webpkg.AdminMQTTStatus { + return webpkg.MQTTRuntimeStatus{ + Server: m.server, + Address: m.address, + TLS: m.tls, + Stats: m.stats, + DBQueue: m.dbQueue, + }.Status() +} + +// 让旧代码里 `mqttforwardpkg.Stats` 别名留作 main 包内可见。 +var _ *mqttforwardpkg.Stats = (*meshtasticMessageStats)(nil) + +func newSessionManager(cfg webAdminConfig) (*auth.Manager, error) { + return auth.NewManager(cfg) +} + +func newRouter(cfg webConfig, store *storepkg.Store, sessions *auth.Manager, mqttStatus webpkg.MQTTStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine { + return webpkg.NewRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender) +} + +func serveHTTPUnixSocket(server *http.Server, socketPath string) error { + return webpkg.ServeUnixSocket(server, socketPath) +}