From 0f9cb3eae5dd2cfcc767eaa9ec106bdbf003030d Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 6 Jun 2026 01:16:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E6=BA=90=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_map_source_routes.go | 147 +++++ db.go | 19 +- db_test.go | 2 +- go.mod | 8 +- map_source_store.go | 320 ++++++++++ map_source_store_test.go | 72 +++ meshmap_frontend/src/App.vue | 13 +- meshmap_frontend/src/api.ts | 28 + .../src/components/AdminMapSource.vue | 545 ++++++++++++++++++ meshmap_frontend/src/components/MeshMap.vue | 37 +- .../src/components/NodeDetailedPage.vue | 11 +- .../src/components/NodeTrajectoryMap.vue | 36 +- meshmap_frontend/src/mapSource.ts | 19 + meshmap_frontend/src/types.ts | 32 + web.go | 2 + 15 files changed, 1267 insertions(+), 24 deletions(-) create mode 100644 admin_map_source_routes.go create mode 100644 map_source_store.go create mode 100644 map_source_store_test.go create mode 100644 meshmap_frontend/src/components/AdminMapSource.vue create mode 100644 meshmap_frontend/src/mapSource.ts diff --git a/admin_map_source_routes.go b/admin_map_source_routes.go new file mode 100644 index 0000000..3461840 --- /dev/null +++ b/admin_map_source_routes.go @@ -0,0 +1,147 @@ +package main + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type mapTileSourceRequest struct { + Name string `json:"name"` + URLTemplate string `json:"url_template"` + Attribution string `json:"attribution"` + MaxZoom int `json:"max_zoom"` + Enabled bool `json:"enabled"` + IsDefault bool `json:"is_default"` +} + +func registerMapSourceRoutes(r gin.IRouter, store *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)}) + }) +} + +func registerAdminMapSourceRoutes(r gin.IRouter, store *store) { + r.GET("/map-source", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + rows, err := store.ListMapTileSources(opts) + if err != nil { + writeListResponse(c, rows, opts, err, mapTileSourceDTO) + return + } + total, err := store.CountMapTileSources(opts) + writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO) + }) + r.POST("/map-source", func(c *gin.Context) { + var req mapTileSourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"}) + return + } + row, err := store.CreateMapTileSource(mapTileSourceInputFromRequest(req)) + writeMapTileSourceMutationResponse(c, http.StatusCreated, row, err) + }) + r.PUT("/map-source/:id", func(c *gin.Context) { + id, ok := parseMapTileSourceID(c) + if !ok { + return + } + var req mapTileSourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"}) + return + } + row, err := store.UpdateMapTileSource(id, mapTileSourceInputFromRequest(req)) + writeMapTileSourceMutationResponse(c, http.StatusOK, row, err) + }) + r.DELETE("/map-source/:id", func(c *gin.Context) { + id, ok := parseMapTileSourceID(c) + if !ok { + return + } + writeMapTileSourceDeleteResponse(c, store.DeleteMapTileSource(id)) + }) + r.POST("/map-source/:id/default", func(c *gin.Context) { + id, ok := parseMapTileSourceID(c) + if !ok { + return + } + row, err := store.SetDefaultMapTileSource(id) + writeMapTileSourceMutationResponse(c, http.StatusOK, row, err) + }) +} + +func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput { + return mapTileSourceInput{ + Name: req.Name, + URLTemplate: req.URLTemplate, + Attribution: req.Attribution, + MaxZoom: req.MaxZoom, + Enabled: req.Enabled, + IsDefault: req.IsDefault, + } +} + +func parseMapTileSourceID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source id"}) + return 0, false + } + return id, true +} + +func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) { + if errors.Is(err, errMapTileSourceAlreadyExists) { + c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { + 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) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)}) +} + +func writeMapTileSourceDeleteResponse(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"}) + return + } + if errors.Is(err, errMapTileSourceCannotDeleteDefault) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func mapTileSourceDTO(row 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, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} +} + +func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H { + return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom} +} diff --git a/db.go b/db.go index 705e9d5..686af94 100644 --- a/db.go +++ b/db.go @@ -115,6 +115,22 @@ func (runtimeSettingRecord) TableName() string { return "runtime_settings" } +type mapTileSourceRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Name string `gorm:"column:name;not null;uniqueIndex"` + URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"` + Attribution string `gorm:"column:attribution"` + MaxZoom int `gorm:"column:max_zoom;not null"` + Enabled bool `gorm:"column:enabled;not null;index"` + IsDefault bool `gorm:"column:is_default;not null;index"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` +} + +func (mapTileSourceRecord) TableName() string { + return "map_tile_sources" +} + type discardDetailsRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Topic string `gorm:"column:topic"` @@ -415,6 +431,7 @@ func (s *store) migrate() error { {label: "login_log", model: &loginLogRecord{}}, {label: "help_content", model: &helpContentRecord{}}, {label: "runtime_settings", model: &runtimeSettingRecord{}}, + {label: "map_tile_sources", model: &mapTileSourceRecord{}}, {label: "discard_details", model: &discardDetailsRecord{}}, {label: "node_blocking", model: &nodeBlockingRecord{}}, {label: "ip_blocking", model: &ipBlockingRecord{}}, @@ -446,7 +463,7 @@ func (s *store) migrate() error { return err } } - return nil + return (&store{db: tx, driver: s.driver}).EnsureDefaultMapTileSource() }) } diff --git a/db_test.go b/db_test.go index 531925e..49e24de 100644 --- a/db_test.go +++ b/db_test.go @@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) { st := openTestStore(t) defer st.Close() - for _, table := range []string{"users", "login_log", "runtime_settings", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { + for _, table := range []string{"users", "login_log", "runtime_settings", "map_tile_sources", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { var name string if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil { t.Fatalf("%s table missing: %v", table, err) diff --git a/go.mod b/go.mod index 6e26832..bc34b44 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,13 @@ module meshtastic_mqtt_server go 1.25.0 require ( + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/mochi-mqtt/server/v2 v2.7.9 + github.com/yuin/goldmark v1.8.2 + golang.org/x/crypto v0.48.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 @@ -20,7 +24,6 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect @@ -39,7 +42,6 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -50,10 +52,8 @@ require ( github.com/rs/xid v1.4.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect - github.com/yuin/goldmark v1.8.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect diff --git a/map_source_store.go b/map_source_store.go new file mode 100644 index 0000000..493b8f0 --- /dev/null +++ b/map_source_store.go @@ -0,0 +1,320 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + "unicode" + + "gorm.io/gorm" +) + +const ( + defaultMapTileSourceName = "OpenStreetMap Japan" + defaultMapTileSourceURLTemplate = "https://tile.openstreetmap.jp/{z}/{x}/{y}.png" + defaultMapTileSourceAttribution = "© OpenStreetMap contributors" + defaultMapTileSourceMaxZoom = 19 + maxMapTileSourceURLLength = 2048 +) + +var ( + errMapTileSourceAlreadyExists = errors.New("map source already exists") + errMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted") + errMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled") + errMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled") +) + +type mapTileSourceInput struct { + Name string + URLTemplate string + Attribution string + MaxZoom int + Enabled bool + IsDefault bool +} + +func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) { + opts = normalizeListOptions(opts) + var rows []mapTileSourceRecord + q := s.db.Model(&mapTileSourceRecord{}). + Order("is_default DESC"). + Order("updated_at DESC"). + Order("id DESC"). + Limit(opts.Limit). + Offset(opts.Offset) + return rows, q.Find(&rows).Error +} + +func (s *store) CountMapTileSources(opts listOptions) (int64, error) { + var total int64 + return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error +} + +func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) { + var row mapTileSourceRecord + err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + fallback := defaultMapTileSourceRecord() + return &fallback, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) { + row, err := mapTileSourceFromInput(input) + if err != nil { + return nil, err + } + if row.IsDefault && !row.Enabled { + return nil, errMapTileSourceDefaultMustBeEnabled + } + if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil { + return nil, err + } + if err := s.db.Transaction(func(tx *gorm.DB) error { + if row.IsDefault { + if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil { + return err + } + } + return tx.Create(row).Error + }); err != nil { + return nil, err + } + return row, nil +} + +func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTileSourceRecord, error) { + if id == 0 { + return nil, fmt.Errorf("map source id is required") + } + row, err := mapTileSourceFromInput(input) + if err != nil { + return nil, err + } + var updated mapTileSourceRecord + if err := s.db.Transaction(func(tx *gorm.DB) error { + var existing mapTileSourceRecord + if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil { + return err + } + if existing.IsDefault && !row.Enabled { + return errMapTileSourceCannotDisableDefault + } + if row.IsDefault && !row.Enabled { + return errMapTileSourceDefaultMustBeEnabled + } + if !row.IsDefault && existing.IsDefault { + row.IsDefault = true + } + if err := ensureMapTileSourceUniqueTx(tx, id, row.Name, row.URLTemplate); err != nil { + return err + } + if row.IsDefault { + if err := tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil { + return err + } + } + updates := map[string]any{ + "name": row.Name, + "url_template": row.URLTemplate, + "attribution": row.Attribution, + "max_zoom": row.MaxZoom, + "enabled": row.Enabled, + "is_default": row.IsDefault, + "updated_at": time.Now(), + } + if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return err + } + return tx.Where("id = ?", id).Take(&updated).Error + }); err != nil { + return nil, err + } + return &updated, nil +} + +func (s *store) DeleteMapTileSource(id uint64) error { + if id == 0 { + return fmt.Errorf("map source id is required") + } + return s.db.Transaction(func(tx *gorm.DB) error { + var row mapTileSourceRecord + if err := tx.Where("id = ?", id).Take(&row).Error; err != nil { + return err + } + if row.IsDefault { + return errMapTileSourceCannotDeleteDefault + } + result := tx.Where("id = ?", id).Delete(&mapTileSourceRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) { + if id == 0 { + return nil, fmt.Errorf("map source id is required") + } + var row mapTileSourceRecord + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ?", id).Take(&row).Error; err != nil { + return err + } + if !row.Enabled { + return errMapTileSourceDefaultMustBeEnabled + } + if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil { + return err + } + if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil { + return err + } + return tx.Where("id = ?", id).Take(&row).Error + }); err != nil { + return nil, err + } + return &row, nil +} + +func (s *store) EnsureDefaultMapTileSource() error { + return s.db.Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&mapTileSourceRecord{}).Count(&count).Error; err != nil { + return err + } + if count == 0 { + row := defaultMapTileSourceRecord() + return tx.Create(&row).Error + } + + var defaults []mapTileSourceRecord + if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil { + return err + } + if len(defaults) > 0 { + return tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error + } + + var enabled mapTileSourceRecord + err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error + if err == nil { + return tx.Model(&mapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + row := defaultMapTileSourceRecord() + var existing mapTileSourceRecord + err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error + if err == nil { + return tx.Model(&mapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(&row).Error + }) +} + +func defaultMapTileSourceRecord() mapTileSourceRecord { + return mapTileSourceRecord{ + Name: defaultMapTileSourceName, + URLTemplate: defaultMapTileSourceURLTemplate, + Attribution: defaultMapTileSourceAttribution, + MaxZoom: defaultMapTileSourceMaxZoom, + Enabled: true, + IsDefault: true, + } +} + +func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, error) { + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, fmt.Errorf("map source name is required") + } + urlTemplate, err := normalizeMapTileSourceURLTemplate(input.URLTemplate) + if err != nil { + return nil, err + } + maxZoom := input.MaxZoom + if maxZoom == 0 { + maxZoom = defaultMapTileSourceMaxZoom + } + if maxZoom < 1 || maxZoom > 30 { + return nil, fmt.Errorf("max zoom must be between 1 and 30") + } + return &mapTileSourceRecord{ + Name: name, + URLTemplate: urlTemplate, + Attribution: strings.TrimSpace(input.Attribution), + MaxZoom: maxZoom, + Enabled: input.Enabled, + IsDefault: input.IsDefault, + }, nil +} + +func normalizeMapTileSourceURLTemplate(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("map source url template is required") + } + if len(value) > maxMapTileSourceURLLength { + return "", fmt.Errorf("map source url template is too long") + } + for _, r := range value { + if unicode.IsControl(r) || unicode.IsSpace(r) { + return "", fmt.Errorf("map source url template must not contain whitespace or control characters") + } + } + for _, placeholder := range []string{"{z}", "{x}", "{y}"} { + if strings.Count(value, placeholder) != 1 { + return "", fmt.Errorf("map source url template must contain %s exactly once", placeholder) + } + } + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("map source url template is invalid") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("map source url template must use http or https") + } + if parsed.Host == "" { + return "", fmt.Errorf("map source url template host is required") + } + if parsed.User != nil { + return "", fmt.Errorf("map source url template must not contain credentials") + } + return value, nil +} + +func (s *store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error { + return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate) +} + +func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error { + var existing mapTileSourceRecord + q := tx.Where("name = ? OR url_template = ?", name, urlTemplate) + if id != 0 { + q = q.Where("id <> ?", id) + } + err := q.Take(&existing).Error + if err == nil { + return errMapTileSourceAlreadyExists + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err +} diff --git a/map_source_store_test.go b/map_source_store_test.go new file mode 100644 index 0000000..fe7aabf --- /dev/null +++ b/map_source_store_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "errors" + "testing" +) + +func TestMapTileSourceDefaultSeeded(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + row, err := st.GetDefaultMapTileSource() + if err != nil { + t.Fatalf("GetDefaultMapTileSource() error = %v", err) + } + if row.Name != defaultMapTileSourceName || row.URLTemplate != defaultMapTileSourceURLTemplate || !row.Enabled || !row.IsDefault { + t.Fatalf("default map source = %+v, want built-in default", row) + } +} + +func TestCreateMapTileSourceValidation(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true}); err == nil { + t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error") + } + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true}); err == nil { + t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error") + } + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true}); err == nil { + t.Fatal("CreateMapTileSource() credentials error = nil, want error") + } +} + +func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}) + if err != nil { + t.Fatalf("CreateMapTileSource() error = %v", err) + } + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) { + t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err) + } + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) { + t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err) + } + + updated, err := st.SetDefaultMapTileSource(first.ID) + if err != nil { + t.Fatalf("SetDefaultMapTileSource() error = %v", err) + } + if !updated.IsDefault { + t.Fatalf("updated default = %+v, want is_default", updated) + } + + oldDefault, err := st.GetDefaultMapTileSource() + if err != nil { + t.Fatalf("GetDefaultMapTileSource() error = %v", err) + } + if oldDefault.ID != first.ID { + t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID) + } + if _, err := st.UpdateMapTileSource(first.ID, mapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, errMapTileSourceCannotDisableDefault) { + t.Fatalf("disable default error = %v, want errMapTileSourceCannotDisableDefault", err) + } + if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, errMapTileSourceCannotDeleteDefault) { + t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err) + } +} diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 9403c7e..a6d706e 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -7,6 +7,7 @@ import AdminDiscardDetails from './components/AdminDiscardDetails.vue' import AdminHelpEdit from './components/AdminHelpEdit.vue' import AdminLogin from './components/AdminLogin.vue' import AdminLoginLogs from './components/AdminLoginLogs.vue' +import AdminMapSource from './components/AdminMapSource.vue' import AdminMqttForward from './components/AdminMqttForward.vue' import AdminUsers from './components/AdminUsers.vue' import ChatPanel from './components/ChatPanel.vue' @@ -15,7 +16,8 @@ import HelpPage from './components/HelpPage.vue' import MeshMap from './components/MeshMap.vue' import NodeDetailedPage from './components/NodeDetailedPage.vue' import NodeListPanel from './components/NodeListPanel.vue' -import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types' +import { fallbackMapSource, loadDefaultMapSource } from './mapSource' +import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types' const currentPath = window.location.pathname const adminPath = currentPath @@ -50,6 +52,7 @@ const currentMapBounds = ref(null) const currentMapZoom = ref(2) const mapReportsLoading = ref(false) const mapReportTotal = ref(0) +const mapSource = ref(fallbackMapSource) const pendingDeleteAction = ref(null) type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] } type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage } @@ -292,6 +295,10 @@ async function refresh(showLoading = true) { } } +async function loadMapSource() { + mapSource.value = await loadDefaultMapSource() +} + async function checkAdminSession() { adminChecking.value = true try { @@ -463,6 +470,7 @@ onMounted(() => { if (isDetailedPage || isHelpPage) { return } + loadMapSource() refresh() refreshTimer = window.setInterval(() => refresh(false), 5000) }) @@ -493,6 +501,7 @@ onBeforeUnmount(() => { 用户管理 屏蔽管理 MQTT转发 + 地图图源 帮助编辑 登录日志 丢弃数据 @@ -532,6 +541,7 @@ onBeforeUnmount(() => { + @@ -570,6 +580,7 @@ onBeforeUnmount(() => { :is-admin="!!adminUser" :auto-fit="false" :loading="mapReportsLoading" + :map-source="mapSource" @bounds-change="handleMapBoundsChange" @select-node="selectedNodeId = $event" @clear-node="selectedNodeId = null" diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index cc4f425..2f92faf 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -18,6 +18,9 @@ import type { ListResponse, MapBoundsQuery, MapReport, + MapTileSource, + MapTileSourcePayload, + MapTileSourceResponse, MapViewportResponse, MQTTForwarder, MQTTForwarderPayload, @@ -29,6 +32,7 @@ import type { NodeBlockingRulePayload, NodeInfo, PositionRecord, + PublicMapTileSourceResponse, TelemetryRecord, TextMessage, } from './types' @@ -125,6 +129,10 @@ export function getMapReportViewport(bounds: MapBoundsQuery, zoom: number, limit return getJSON(`/api/map-reports/viewport?${params.toString()}`) } +export function getDefaultMapSource(): Promise { + return getJSON('/api/map-source/default') +} + export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise> { return getJSON>(listPath('/api/text-messages', limit, offset, nodeId)) } @@ -201,6 +209,26 @@ export function getAdminLoginLogs(limit = 100, offset = 0): Promise(`/api/admin/log/login?limit=${limit}&offset=${offset}`) } +export function getAdminMapSources(limit = 100, offset = 0): Promise> { + return getJSON>(listPath('/api/admin/map-source', limit, offset)) +} + +export function createAdminMapSource(payload: MapTileSourcePayload): Promise { + return postJSON('/api/admin/map-source', payload) +} + +export function updateAdminMapSource(id: number, payload: MapTileSourcePayload): Promise { + return putJSON(`/api/admin/map-source/${id}`, payload) +} + +export function deleteAdminMapSource(id: number): Promise<{ status: string }> { + return deleteJSON<{ status: string }>(`/api/admin/map-source/${id}`) +} + +export function setDefaultAdminMapSource(id: number): Promise { + return postJSON(`/api/admin/map-source/${id}/default`) +} + export function getNodeBlockingRules(limit = 100, offset = 0): Promise> { return getJSON>(listPath('/api/admin/blocking/nodes', limit, offset)) } diff --git a/meshmap_frontend/src/components/AdminMapSource.vue b/meshmap_frontend/src/components/AdminMapSource.vue new file mode 100644 index 0000000..cf1bd39 --- /dev/null +++ b/meshmap_frontend/src/components/AdminMapSource.vue @@ -0,0 +1,545 @@ + + + + + diff --git a/meshmap_frontend/src/components/MeshMap.vue b/meshmap_frontend/src/components/MeshMap.vue index fa43bf9..d5190fd 100644 --- a/meshmap_frontend/src/components/MeshMap.vue +++ b/meshmap_frontend/src/components/MeshMap.vue @@ -2,7 +2,8 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import L from 'leaflet' import 'leaflet/dist/leaflet.css' -import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable } from '../types' +import { fallbackMapSource } from '../mapSource' +import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable, PublicMapTileSource } from '../types' const props = withDefaults(defineProps<{ items: MapRenderable[] @@ -10,9 +11,11 @@ const props = withDefaults(defineProps<{ isAdmin: boolean autoFit?: boolean loading?: boolean + mapSource?: PublicMapTileSource }>(), { autoFit: true, loading: false, + mapSource: () => fallbackMapSource, }) const emit = defineEmits<{ @@ -29,6 +32,7 @@ const menuX = ref(0) const menuY = ref(0) const lastRaisedNodeId = ref(null) let map: L.Map | null = null +let tileLayer: L.TileLayer | null = null let markerLayer: L.LayerGroup | null = null const markersByKey = new Map() let hasFitBounds = false @@ -55,13 +59,7 @@ onMounted(async () => { maxBoundsViscosity: 1.0, worldCopyJump: false, }).setView(defaultMapCenter, defaultMapZoom) - L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', { - minZoom: minMapZoom, - maxZoom: 19, - noWrap: true, - bounds: worldBounds, - attribution: '© OpenStreetMap contributors', - }).addTo(map) + applyTileLayer() map.on('click', () => { closeNodeMenu() emit('clear-node') @@ -77,6 +75,7 @@ onBeforeUnmount(() => { window.removeEventListener('keydown', handleKeydown) map?.remove() map = null + tileLayer = null markerLayer = null markersByKey.clear() }) @@ -87,6 +86,28 @@ watch( { deep: true }, ) +watch( + () => props.mapSource, + () => applyTileLayer(), + { deep: true }, +) + +function applyTileLayer() { + if (!map) { + return + } + if (tileLayer) { + tileLayer.remove() + } + tileLayer = L.tileLayer(props.mapSource.url_template, { + minZoom: minMapZoom, + maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom, + noWrap: true, + bounds: worldBounds, + attribution: props.mapSource.attribution || fallbackMapSource.attribution, + }).addTo(map) +} + function closeNodeMenu() { menuNode.value = null } diff --git a/meshmap_frontend/src/components/NodeDetailedPage.vue b/meshmap_frontend/src/components/NodeDetailedPage.vue index d041903..3ca464d 100644 --- a/meshmap_frontend/src/components/NodeDetailedPage.vue +++ b/meshmap_frontend/src/components/NodeDetailedPage.vue @@ -1,7 +1,8 @@