重构:拆出 bot / sign / mapsource / llmadmin / web 包

第三批(最终批):把 root 中剩下所有领域文件按功能搬到 internal/ 下。
完成后根目录从 46 个 .go 文件降到 14 个,其中 11 个是 bridge(type alias
+ thin wrapper),仅供尚未改造的 main.go 引用。

新增包
- internal/bot/         Service / TextSender / NewPKIKeyResolver /
                        RegisterRoutes,含 PKI 直连发送、节点信息广播、
                        outbound DM 持久化等业务逻辑。
- internal/sign/        SignDTO / SignDayCountDTO / RegisterAdminRoutes,
                        把原来分散在 admin_sign_routes.go 与 web.go 中的
                        sign DTO/路由收拢到一处。
- internal/mapsource/   AdminDTO / PublicDTO / RegisterAdminRoutes /
                        RegisterPublicRoutes。
- internal/llmadmin/    LLM 消息队列、Provider、ToolRouter、PrimaryConfig 的
                        admin 路由。
- internal/web/         路由总入口(NewRouter/NewHTTPServer/ServeUnixSocket)、
                        各资源的 GET API、admin 用户/登录/MQTT 状态、所有
                        DTO 函数。把 auth.go 的 sessionClaims 升级为
                        auth.SessionClaims;mqtt_status.go 重写成
                        MQTTRuntimeStatus / AdminMQTTStatus 结构体并把
                        client info 解析在 web 包内自带,不再依赖 main 包。
                        map_tile_proxy_routes 与测试一起搬入。

修改
- web.go 中 parseListOptions / writeListResponse / ptrString 等本地 helper
  改为对 internal/webutil 的 thin wrapper,避免重复实现。
- internal/auth 在 step 4 已创建,本批中 web 包正式开始引用其 Manager /
  RequireAdmin / SessionClaims。

根目录新增 bridge:bot_bridge.go / sign_bridge.go / mapsource_bridge.go /
llmadmin_bridge.go / web_bridge.go。后者把 mqttRuntimeStatus 包成
webpkg.MQTTStatusProvider 适配器,使 main.go 中旧字段名保持可用。

go build ./... / go test ./... 全部通过;测试数量未变。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:24:14 +08:00
co-authored by Claude
parent c527a9fd9a
commit 9394aa0f4a
18 changed files with 565 additions and 530 deletions
+202
View File
@@ -0,0 +1,202 @@
package web
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
storepkg "meshtastic_mqtt_server/internal/store"
)
const (
mapTileCacheControl = "public, max-age=86400"
maxMapTileBytes = 10 << 20
)
type mapTileProxy struct {
store *storepkg.Store
cacheDir string
client *http.Client
}
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
proxy := &mapTileProxy{
store: store,
cacheDir: cacheDir,
client: &http.Client{Timeout: 15 * time.Second},
}
r.GET("/map/:sourceHash", proxy.handle)
}
func (p *mapTileProxy) handle(c *gin.Context) {
sourceHash := strings.ToLower(c.Param("sourceHash"))
if !isMapTileSourceHash(sourceHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source hash"})
return
}
row, err := p.store.GetEnabledMapTileSourceByHash(sourceHash)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
tile, ok := parseMapTileCoordinates(c, row.MaxZoom)
if !ok {
return
}
cachePath := mapTileCachePath(p.cacheDir, sourceHash, tile)
if data, err := os.ReadFile(cachePath); err == nil {
writeMapTile(c, data)
return
} else if !os.IsNotExist(err) {
// Fall through to upstream fetch. A broken cache file should not prevent map rendering.
}
data, status, err := p.fetchRemoteTile(c.Request, row.URLTemplate, tile)
if err != nil {
c.JSON(status, gin.H{"error": err.Error()})
return
}
_ = writeMapTileCacheFile(cachePath, data)
writeMapTile(c, data)
}
func (p *mapTileProxy) fetchRemoteTile(req *http.Request, template string, tile mapTileCoordinates) ([]byte, int, error) {
remoteURL := expandMapTileURLTemplate(template, tile)
upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil)
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("build upstream map tile request: %w", err)
}
upstreamReq.Header.Set("User-Agent", "mesh_mqtt_go map tile cache")
resp, err := p.client.Do(upstreamReq)
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("fetch upstream map tile: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, http.StatusNotFound, fmt.Errorf("upstream map tile not found")
}
if resp.StatusCode != http.StatusOK {
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxMapTileBytes+1))
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("read upstream map tile: %w", err)
}
if len(data) > maxMapTileBytes {
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile is too large")
}
return data, http.StatusOK, nil
}
type mapTileCoordinates struct {
x int64
y int64
z int64
}
func parseMapTileCoordinates(c *gin.Context, maxZoom int) (mapTileCoordinates, bool) {
x, ok := parseMapTileCoordinate(c, "x")
if !ok {
return mapTileCoordinates{}, false
}
y, ok := parseMapTileCoordinate(c, "y")
if !ok {
return mapTileCoordinates{}, false
}
z, ok := parseMapTileCoordinate(c, "z")
if !ok {
return mapTileCoordinates{}, false
}
if z > int64(maxZoom) {
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile z exceeds max zoom"})
return mapTileCoordinates{}, false
}
limit := int64(1) << z
if x >= limit || y >= limit {
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile coordinates out of range"})
return mapTileCoordinates{}, false
}
return mapTileCoordinates{x: x, y: y, z: z}, true
}
func parseMapTileCoordinate(c *gin.Context, name string) (int64, bool) {
value := c.Query(name)
if value == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing map tile " + name})
return 0, false
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 || parsed > 30_000_000_000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map tile " + name})
return 0, false
}
return parsed, true
}
func isMapTileSourceHash(value string) bool {
if len(value) != 64 {
return false
}
for _, r := range value {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
func expandMapTileURLTemplate(template string, tile mapTileCoordinates) string {
result := strings.ReplaceAll(template, "{x}", strconv.FormatInt(tile.x, 10))
result = strings.ReplaceAll(result, "{y}", strconv.FormatInt(tile.y, 10))
result = strings.ReplaceAll(result, "{z}", strconv.FormatInt(tile.z, 10))
return result
}
func mapTileCachePath(cacheDir, sourceHash string, tile mapTileCoordinates) string {
return filepath.Join(cacheDir, sourceHash, strconv.FormatInt(tile.z, 10), strconv.FormatInt(tile.x, 10), strconv.FormatInt(tile.y, 10)+".tile")
}
func writeMapTileCacheFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func writeMapTile(c *gin.Context, data []byte) {
contentType := http.DetectContentType(data)
c.Header("Cache-Control", mapTileCacheControl)
c.Data(http.StatusOK, contentType, data)
}
+167
View File
@@ -0,0 +1,167 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"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()
requests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != "/3/1/2.png" {
t.Fatalf("upstream path = %q, want /3/1/2.png", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("tile-data"))
}))
defer upstream.Close()
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(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++ {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("request %d status = %d, body = %s", i+1, recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != "tile-data" {
t.Fatalf("request %d body = %q, want tile-data", i+1, recorder.Body.String())
}
}
if requests != 1 {
t.Fatalf("upstream requests = %d, want 1", requests)
}
cachePath := filepath.Join(cacheDir, row.URLTemplateHash, "3", "1", "2.tile")
data, err := os.ReadFile(cachePath)
if err != nil {
t.Fatalf("read cache file %s: %v", cachePath, err)
}
if string(data) != "tile-data" {
t.Fatalf("cache file = %q, want tile-data", string(data))
}
}
func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
st := openTestStore(t)
defer st.Close()
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(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",
"/api/map/" + row.URLTemplateHash + "?x=-1&y=0&z=0",
"/api/map/" + row.URLTemplateHash + "?x=0&y=0&z=4",
"/api/map/" + row.URLTemplateHash + "?x=2&y=0&z=1",
}
for _, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d, want 400; body = %s", url, recorder.Code, recorder.Body.String())
}
}
}
func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
st := openTestStore(t)
defer st.Close()
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(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(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",
"/api/map/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=0&y=0&z=0",
"/api/map/" + disabled.URLTemplateHash + "?x=0&y=0&z=0",
"/api/map/" + proxyDisabled.URLTemplateHash + "?x=0&y=0&z=0",
}
wantStatus := []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotFound, http.StatusNotFound}
for i, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != wantStatus[i] {
t.Fatalf("%s status = %d, want %d; body = %s", url, recorder.Code, wantStatus[i], recorder.Body.String())
}
}
}
func TestMapTileProxyUpstreamStatus(t *testing.T) {
st := openTestStore(t)
defer st.Close()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/404/") {
http.NotFound(w, r)
return
}
http.Error(w, "upstream error", http.StatusInternalServerError)
}))
defer upstream.Close()
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(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(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
cases := []struct {
url string
want int
}{
{url: "/api/map/" + row404.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusNotFound},
{url: "/api/map/" + row500.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusBadGateway},
}
for _, tc := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != tc.want {
t.Fatalf("%s status = %d, want %d; body = %s", tc.url, recorder.Code, tc.want, recorder.Body.String())
}
}
}
+150
View File
@@ -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, ""
}
+521
View File
@@ -0,0 +1,521 @@
package web
import (
"errors"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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 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),
}
}
func ServeUnixSocket(server *http.Server, socketPath string) error {
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
return err
}
if info, err := os.Stat(socketPath); err == nil {
if info.Mode()&os.ModeSocket == 0 {
return errors.New("web socket path exists and is not a socket")
}
if err := os.Remove(socketPath); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
return err
}
defer os.Remove(socketPath)
if err := os.Chmod(socketPath, 0660); err != nil {
listener.Close()
return err
}
return server.Serve(listener)
}
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")
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
registerStaticRoutes(r, cfg.StaticDir)
return r
}
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 {
status["status"] = "error"
status["database"] = err.Error()
c.JSON(http.StatusServiceUnavailable, status)
return
}
c.JSON(http.StatusOK, status)
})
registerNodeInfoRoutes(r, store, "/nodeinfo")
registerNodeInfoRoutes(r, store, "/nodes")
registerMapReportRoutes(r, store)
mappkg.RegisterPublicRoutes(r, store)
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
helppkg.RegisterPublicRoutes(r, store)
r.GET("/signs", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListSigns(opts)
if err != nil {
writeListResponse(c, rows, opts, err, signpkg.SignDTO)
return
}
total, err := store.CountSigns(opts)
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
})
r.GET("/signs/daily", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.CountSignsByDay(opts)
writeListResponse(c, rows, opts, err, signpkg.SignDayCountDTO)
})
r.GET("/text-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListTextMessages(opts)
writeListResponse(c, rows, opts, err, textMessageDTO)
})
r.GET("/discard-details", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListDiscardDetails(opts)
writeListResponse(c, rows, opts, err, discardDetailsDTO)
})
r.GET("/positions", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListPositions(opts)
writeListResponse(c, rows, opts, err, positionDTO)
})
r.GET("/telemetry", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListTelemetry(opts)
writeListResponse(c, rows, opts, err, telemetryDTO)
})
r.GET("/routing", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListRouting(opts)
writeListResponse(c, rows, opts, err, routingDTO)
})
r.GET("/traceroute", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListTraceroute(opts)
writeListResponse(c, rows, opts, err, tracerouteDTO)
})
}
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"`
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type updatePasswordRequest struct {
Password string `json:"password"`
}
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 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) {
remoteAddr := c.Request.RemoteAddr
remoteHost, _, err := net.SplitHostPort(remoteAddr)
if err != nil || remoteHost == "" {
remoteHost = remoteAddr
}
return remoteAddr, remoteHost
}
recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
remoteAddr, remoteHost := remoteInfo(c)
_ = 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) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
recordLogin(c, "", nil, false, "invalid request")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"})
return
}
user, err := store.GetUserByUsername(req.Username)
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
}
cookie, err := sessions.NewCookie(*user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
recordLogin(c, req.Username, &user.ID, true, "success")
http.SetCookie(c.Writer, cookie)
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserResponse(*user)})
})
r.POST("/logout", func(c *gin.Context) {
http.SetCookie(c.Writer, sessions.ClearCookie())
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
protected := r.Group("")
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").(*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})
return
}
status := mqttStatus.Status()
discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
status.MessagesDropped = discardCount
c.JSON(http.StatusOK, status)
})
protected.GET("/users", func(c *gin.Context) {
users, err := store.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(users))
for _, user := range users {
items = append(items, userDTO(user))
}
c.JSON(http.StatusOK, gin.H{"items": items})
})
protected.POST("/users", func(c *gin.Context) {
var req createUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid create user request"})
return
}
user, err := store.CreateAdminUser(req.Username, req.Password)
if errors.Is(err, storepkg.ErrUserAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"user": userDTO(*user)})
})
protected.PUT("/users/:id/password", func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
return
}
var req updatePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid password request"})
return
}
user, err := store.UpdateUserPassword(id, req.Password)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"user": userDTO(*user)})
})
protected.GET("/log/login", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListLoginLogs(opts)
writeListResponse(c, rows, opts, err, loginLogDTO)
})
protected.DELETE("/text-messages/:id", func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
return
}
if err := store.DeleteTextMessage(id); errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
protected.DELETE("/nodes/:id", func(c *gin.Context) {
nodeID := c.Param("id")
if nodeID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
return
}
if err := store.DeleteNode(nodeID); errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "node not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
}
func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) {
r.GET(path, func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListNodeInfo(opts)
if err != nil {
writeListResponse(c, rows, opts, err, nodeInfoDTO)
return
}
total, err := store.CountNodeInfo(opts)
writeListResponseWithTotal(c, rows, opts, total, err, nodeInfoDTO)
})
r.GET(path+"/:id", func(c *gin.Context) {
row, err := store.GetNodeInfo(c.Param("id"))
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "nodeinfo not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, nodeInfoDTO(*row))
})
}
func registerMapReportRoutes(r gin.IRouter, store *storepkg.Store) {
r.GET("/map-reports/viewport", func(c *gin.Context) {
opts, ok := parseMapReportViewportOptions(c)
if !ok {
return
}
result, err := store.ListMapReportViewport(opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(result.Points)+len(result.Clusters))
if result.Mode == "points" {
for _, row := range result.Points {
items = append(items, mapReportViewportPointDTO(row))
}
} else {
for _, row := range result.Clusters {
items = append(items, mapReportClusterDTO(row))
}
}
c.JSON(http.StatusOK, gin.H{"mode": result.Mode, "items": items, "total": result.Total, "limit": result.Limit, "zoom": result.Zoom})
})
r.GET("/map-reports", func(c *gin.Context) {
opts, ok := parseMapReportListOptions(c)
if !ok {
return
}
rows, err := store.ListMapReports(opts)
if err != nil {
writeListResponse(c, rows, opts, err, mapReportDTO)
return
}
total, err := store.CountMapReports(opts)
writeListResponseWithTotal(c, rows, opts, total, err, mapReportDTO)
})
r.GET("/map-reports/:id", func(c *gin.Context) {
row, err := store.GetMapReport(c.Param("id"))
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map report not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, mapReportDTO(*row))
})
}
func registerStaticRoutes(r *gin.Engine, staticDir string) {
assetsDir := filepath.Join(staticDir, "assets")
if info, err := os.Stat(assetsDir); err == nil && info.IsDir() {
r.Static("/assets", assetsDir)
}
r.GET("/", func(c *gin.Context) {
serveIndex(c, staticDir)
})
r.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if filepath.Ext(c.Request.URL.Path) != "" {
c.Status(http.StatusNotFound)
return
}
serveIndex(c, staticDir)
})
}
func serveIndex(c *gin.Context, staticDir string) {
indexPath := filepath.Join(staticDir, "index.html")
if _, err := os.Stat(indexPath); err != nil {
c.String(http.StatusNotFound, "frontend dist not found: run npm run build in meshmap_frontend")
return
}
c.File(indexPath)
}
func parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
return webutil.ParseListOptions(c)
}
func parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
return webutil.ParseMapReportListOptions(c)
}
func parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) {
return webutil.ParseMapReportViewportOptions(c)
}
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
return webutil.ParseIntQuery(c, name, defaultValue)
}
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 storepkg.ListOptions, total int64, err error, convert func(T) gin.H) {
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert)
}
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 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 storepkg.MapReportRecord) gin.H {
item := mapReportDTO(row)
item["type"] = "point"
return item
}
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 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 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 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 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 storepkg.RoutingRecord) gin.H {
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
}
func tracerouteDTO(row storepkg.TracerouteRecord) gin.H {
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
}
func appendPacketDTO(id uint64, fromID string, fromNum int64, packetID *int64, portnum *string, createdAt time.Time, contentJSON string) gin.H {
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 { 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) }