From 2faac32d870a6c727f1b8b8cb5f4a0df273adfb3 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 6 Jun 2026 12:20:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=90=86=E4=B8=8E=E5=90=A6=E5=8F=AF?= =?UTF-8?q?=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_map_source_routes.go | 40 +++++++------ db.go | 10 ++++ map_source_store.go | 18 +++--- map_source_store_test.go | 57 +++++++++++++++---- map_tile_proxy_routes_test.go | 17 ++++-- .../src/components/AdminMapSource.vue | 5 ++ meshmap_frontend/src/types.ts | 2 + 7 files changed, 109 insertions(+), 40 deletions(-) diff --git a/admin_map_source_routes.go b/admin_map_source_routes.go index c8bf937..43172ff 100644 --- a/admin_map_source_routes.go +++ b/admin_map_source_routes.go @@ -10,12 +10,13 @@ import ( ) 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"` + 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"` + ProxyEnabled bool `json:"proxy_enabled"` } func registerMapSourceRoutes(r gin.IRouter, store *store) { @@ -96,12 +97,13 @@ func registerAdminMapSourceRoutes(r gin.IRouter, store *store) { 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, + Name: req.Name, + URLTemplate: req.URLTemplate, + Attribution: req.Attribution, + MaxZoom: req.MaxZoom, + Enabled: req.Enabled, + IsDefault: req.IsDefault, + ProxyEnabled: req.ProxyEnabled, } } @@ -151,13 +153,17 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) { } 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} + 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 { - hash := row.URLTemplateHash - if hash == "" { - hash = mapTileSourceHash(row.URLTemplate) + urlTemplate := row.URLTemplate + if row.ProxyEnabled { + hash := row.URLTemplateHash + if hash == "" { + hash = mapTileSourceHash(row.URLTemplate) + } + urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}" } - return gin.H{"id": row.ID, "name": row.Name, "url_template": "/api/map/" + hash + "?x={x}&y={y}&z={z}", "attribution": row.Attribution, "max_zoom": row.MaxZoom} + return gin.H{"id": row.ID, "name": row.Name, "url_template": urlTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom} } diff --git a/db.go b/db.go index 9dbaeb7..7bb5f73 100644 --- a/db.go +++ b/db.go @@ -124,6 +124,7 @@ type mapTileSourceRecord struct { 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"` + ProxyEnabled bool `gorm:"column:proxy_enabled;not null;index"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` } @@ -472,6 +473,15 @@ func (s *store) migrate() error { } func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error { + if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") { + if driver == databaseDriverSQLite { + if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN proxy_enabled numeric NOT NULL DEFAULT true").Error; err != nil { + return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err) + } + } else if err := migrator.AddColumn(&mapTileSourceRecord{}, "ProxyEnabled"); err != nil { + return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err) + } + } if !migrator.HasColumn(&mapTileSourceRecord{}, "URLTemplateHash") { if driver == databaseDriverSQLite { if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN url_template_hash TEXT NOT NULL DEFAULT ''").Error; err != nil { diff --git a/map_source_store.go b/map_source_store.go index fd66302..fce0252 100644 --- a/map_source_store.go +++ b/map_source_store.go @@ -29,12 +29,13 @@ var ( ) type mapTileSourceInput struct { - Name string - URLTemplate string - Attribution string - MaxZoom int - Enabled bool - IsDefault bool + Name string + URLTemplate string + Attribution string + MaxZoom int + Enabled bool + IsDefault bool + ProxyEnabled bool } func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) { @@ -85,7 +86,7 @@ func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) { func (s *store) GetEnabledMapTileSourceByHash(hash string) (*mapTileSourceRecord, error) { var row mapTileSourceRecord - if err := s.db.Where("enabled = ? AND url_template_hash = ?", true, hash).Take(&row).Error; err != nil { + if err := s.db.Where("enabled = ? AND proxy_enabled = ? AND url_template_hash = ?", true, true, hash).Take(&row).Error; err != nil { return nil, err } return &row, nil @@ -154,6 +155,7 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, + "proxy_enabled": row.ProxyEnabled, "updated_at": time.Now(), } if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { @@ -269,6 +271,7 @@ func defaultMapTileSourceRecord() mapTileSourceRecord { MaxZoom: defaultMapTileSourceMaxZoom, Enabled: true, IsDefault: true, + ProxyEnabled: true, } } @@ -296,6 +299,7 @@ func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, err MaxZoom: maxZoom, Enabled: input.Enabled, IsDefault: input.IsDefault, + ProxyEnabled: input.ProxyEnabled, }, nil } diff --git a/map_source_store_test.go b/map_source_store_test.go index c97bad1..d8350ce 100644 --- a/map_source_store_test.go +++ b/map_source_store_test.go @@ -25,13 +25,13 @@ 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 { + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error") } - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true}); err == nil { + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error") } - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true}); err == nil { + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil { t.Fatal("CreateMapTileSource() credentials error = nil, want error") } } @@ -44,7 +44,7 @@ func TestListEnabledMapTileSources(t *testing.T) { if err != nil { t.Fatalf("CreateMapTileSource(disabled) error = %v", err) } - custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}) + custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource(custom) error = %v", err) } @@ -76,14 +76,14 @@ 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}) + first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { 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) { + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) { t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err) } - if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) { + if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) { t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err) } @@ -114,7 +114,7 @@ func TestMapTileSourceHashIsSetOnCreate(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}) + row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -122,6 +122,9 @@ func TestMapTileSourceHashIsSetOnCreate(t *testing.T) { if row.URLTemplateHash != want { t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want) } + if !row.ProxyEnabled { + t.Fatal("ProxyEnabled = false, want true") + } } func TestMapTileSourceDefaultHasHash(t *testing.T) { @@ -142,7 +145,7 @@ func TestGetEnabledMapTileSourceByHash(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}) + row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -171,6 +174,21 @@ func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) { } } +func TestGetEnabledMapTileSourceByHashProxyDisabled(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) + if err != nil { + t.Fatalf("CreateMapTileSource() error = %v", err) + } + + _, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("GetEnabledMapTileSourceByHash(proxy disabled) = %v, want gorm.ErrRecordNotFound", err) + } +} + func TestGetEnabledMapTileSourceByHashUnknown(t *testing.T) { st := openTestStore(t) defer st.Close() @@ -185,7 +203,7 @@ func TestPublicMapTileSourceDTOProxyURL(t *testing.T) { st := openTestStore(t) defer st.Close() - row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true}) + row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}) if err != nil { t.Fatalf("CreateMapTileSource() error = %v", err) } @@ -204,6 +222,25 @@ func TestPublicMapTileSourceDTOProxyURL(t *testing.T) { } } +func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false}) + if err != nil { + t.Fatalf("CreateMapTileSource() error = %v", err) + } + + dto := publicMapTileSourceDTO(*row) + urlTemplate, ok := dto["url_template"].(string) + if !ok { + t.Fatal("url_template is not a string") + } + if urlTemplate != row.URLTemplate { + t.Fatalf("url_template = %q, want raw %q", urlTemplate, row.URLTemplate) + } +} + func TestMapTileSourceHashFunction(t *testing.T) { hash1 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") hash2 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png") diff --git a/map_tile_proxy_routes_test.go b/map_tile_proxy_routes_test.go index d7f67f6..f046130 100644 --- a/map_tile_proxy_routes_test.go +++ b/map_tile_proxy_routes_test.go @@ -24,7 +24,7 @@ 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}) + row, err := st.CreateMapTileSource(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) } @@ -62,7 +62,7 @@ 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}) + row, err := st.CreateMapTileSource(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) } @@ -91,7 +91,11 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) { disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false}) if err != nil { - t.Fatalf("CreateMapTileSource() error = %v", err) + t.Fatalf("CreateMapTileSource(disabled) error = %v", err) + } + proxyDisabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false}) + 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) @@ -100,8 +104,9 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) { "/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} + 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) @@ -125,11 +130,11 @@ 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}) + row404, err := st.CreateMapTileSource(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}) + row500, err := st.CreateMapTileSource(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) } diff --git a/meshmap_frontend/src/components/AdminMapSource.vue b/meshmap_frontend/src/components/AdminMapSource.vue index a4cee0a..bcb4ee0 100644 --- a/meshmap_frontend/src/components/AdminMapSource.vue +++ b/meshmap_frontend/src/components/AdminMapSource.vue @@ -17,6 +17,7 @@ const newSource = ref({ max_zoom: 19, enabled: true, is_default: false, + proxy_enabled: true, }) const canPrev = () => page.value > 1 @@ -32,6 +33,7 @@ function editableCopy(item: MapTileSource): MapTileSourcePayload { max_zoom: item.max_zoom, enabled: item.enabled, is_default: item.is_default, + proxy_enabled: item.proxy_enabled, } } @@ -45,6 +47,7 @@ function resetNewSource() { max_zoom: 19, enabled: true, is_default: false, + proxy_enabled: true, } } @@ -214,6 +217,7 @@ onMounted(refreshItems) +
@@ -254,6 +258,7 @@ onMounted(refreshItems) +
diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index 41c0ed7..d44f012 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -87,6 +87,7 @@ export interface PublicMapTileSource { export interface MapTileSource extends PublicMapTileSource { enabled: boolean is_default: boolean + proxy_enabled: boolean created_at: string updated_at: string } @@ -98,6 +99,7 @@ export interface MapTileSourcePayload { max_zoom: number enabled: boolean is_default: boolean + proxy_enabled: boolean } export interface MapTileSourceResponse {