diff --git a/admin_map_source_routes.go b/admin_map_source_routes.go index 7da03a4..c8bf937 100644 --- a/admin_map_source_routes.go +++ b/admin_map_source_routes.go @@ -155,5 +155,9 @@ func mapTileSourceDTO(row mapTileSourceRecord) gin.H { } 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} + hash := row.URLTemplateHash + if hash == "" { + hash = mapTileSourceHash(row.URLTemplate) + } + 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} } diff --git a/config.go b/config.go index 4e3b72a..98f4a57 100644 --- a/config.go +++ b/config.go @@ -51,12 +51,13 @@ type mysqlConfig struct { } type webConfig struct { - Enabled bool `yaml:"enabled"` - Host string `yaml:"host"` - Port int `yaml:"port"` - SocketPath string `yaml:"socket_path"` - StaticDir string `yaml:"static_dir"` - Admin webAdminConfig `yaml:"admin"` + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + Port int `yaml:"port"` + SocketPath string `yaml:"socket_path"` + StaticDir string `yaml:"static_dir"` + MapTileCacheDir string `yaml:"map_tile_cache_dir"` + Admin webAdminConfig `yaml:"admin"` } type webAdminConfig struct { @@ -104,12 +105,13 @@ type rawMySQLConfig struct { } type rawWebConfig struct { - Enabled *bool `yaml:"enabled"` - Host *string `yaml:"host"` - Port *int `yaml:"port"` - SocketPath *string `yaml:"socket_path"` - StaticDir *string `yaml:"static_dir"` - Admin *rawWebAdminConfig `yaml:"admin"` + Enabled *bool `yaml:"enabled"` + Host *string `yaml:"host"` + Port *int `yaml:"port"` + SocketPath *string `yaml:"socket_path"` + StaticDir *string `yaml:"static_dir"` + MapTileCacheDir *string `yaml:"map_tile_cache_dir"` + Admin *rawWebAdminConfig `yaml:"admin"` } type rawWebAdminConfig struct { @@ -140,11 +142,12 @@ func defaultConfig() *config { MySQL: mysqlConfig{DSN: ""}, }, Web: webConfig{ - Enabled: true, - Host: "0.0.0.0", - Port: 8080, - SocketPath: defaultWebSocketPath(), - StaticDir: "./dist", + Enabled: true, + Host: "0.0.0.0", + Port: 8080, + SocketPath: defaultWebSocketPath(), + StaticDir: "./dist", + MapTileCacheDir: defaultMapTileCacheDir(), Admin: webAdminConfig{ Username: "admin", Password: "admin", @@ -176,6 +179,17 @@ func defaultWebSocketPath() string { return defaultWebSocketPathForGOOS(runtime.GOOS) } +func defaultMapTileCacheDir() string { + return defaultMapTileCacheDirForGOOS(runtime.GOOS) +} + +func defaultMapTileCacheDirForGOOS(goos string) string { + if goos == "windows" { + return filepath.Join(".", "win", "srv", "mesh_mqtt_go") + } + return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go") +} + func defaultWebSocketPathForGOOS(goos string) string { if goos == "windows" { return "" @@ -342,6 +356,11 @@ func normalizeConfig(raw rawConfig) (*config, bool) { } else { cfg.Web.StaticDir = *raw.Web.StaticDir } + if raw.Web.MapTileCacheDir == nil { + changed = true + } else { + cfg.Web.MapTileCacheDir = *raw.Web.MapTileCacheDir + } if raw.Web.Admin == nil { changed = true } else { @@ -394,6 +413,9 @@ func validateConfig(cfg *config) error { if cfg.Web.StaticDir == "" { return fmt.Errorf("web.static_dir is required when web is enabled") } + if cfg.Web.MapTileCacheDir == "" { + return fmt.Errorf("web.map_tile_cache_dir is required when web is enabled") + } if cfg.Web.Admin.Username == "" { return fmt.Errorf("web.admin.username is required when web is enabled") } diff --git a/config_test.go b/config_test.go index 6a480ff..fa86a6e 100644 --- a/config_test.go +++ b/config_test.go @@ -44,6 +44,9 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) { if cfg.Web.StaticDir != "./dist" { t.Fatalf("web static dir = %q, want ./dist", cfg.Web.StaticDir) } + if cfg.Web.MapTileCacheDir != defaultMapTileCacheDir() { + t.Fatalf("web map tile cache dir = %q, want %q", cfg.Web.MapTileCacheDir, defaultMapTileCacheDir()) + } if _, err := os.Stat(path); err != nil { t.Fatalf("default config was not written: %v", err) } @@ -80,7 +83,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) { t.Fatal(err) } text := string(data) - for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:"} { + for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} { if !strings.Contains(text, want) { t.Fatalf("completed config missing %q in:\n%s", want, text) } @@ -154,6 +157,20 @@ func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) { } } +func TestDefaultMapTileCacheDirForGOOS(t *testing.T) { + windowsPath := defaultMapTileCacheDirForGOOS("windows") + wantWindows := filepath.Join(".", "win", "srv", "mesh_mqtt_go") + if windowsPath != wantWindows { + t.Fatalf("windows map tile cache dir = %q, want %q", windowsPath, wantWindows) + } + + linuxPath := defaultMapTileCacheDirForGOOS("linux") + wantLinux := filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go") + if linuxPath != wantLinux { + t.Fatalf("linux map tile cache dir = %q, want %q", linuxPath, wantLinux) + } +} + func TestDefaultWebSocketPathForGOOS(t *testing.T) { if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" { t.Fatalf("windows web socket path = %q, want empty", windowsPath) @@ -228,6 +245,7 @@ func TestValidateConfigWeb(t *testing.T) { } cfg = defaultConfig() + cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock") cfg.Web.Port = 0 if err := validateConfig(cfg); err != nil { t.Fatalf("web socket with invalid port error = %v, want nil", err) @@ -246,6 +264,12 @@ func TestValidateConfigWeb(t *testing.T) { t.Fatalf("missing web static dir error = %v, want web.static_dir error", err) } + cfg = defaultConfig() + cfg.Web.MapTileCacheDir = "" + if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") { + t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err) + } + cfg = defaultConfig() cfg.Web.Enabled = false cfg.Web.Port = 0 diff --git a/db.go b/db.go index 686af94..9dbaeb7 100644 --- a/db.go +++ b/db.go @@ -116,15 +116,16 @@ func (runtimeSettingRecord) TableName() string { } 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"` + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Name string `gorm:"column:name;not null;uniqueIndex"` + URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"` + URLTemplateHash string `gorm:"column:url_template_hash;size:64;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 { @@ -463,10 +464,41 @@ func (s *store) migrate() error { return err } } + if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil { + return err + } return (&store{db: tx, driver: s.driver}).EnsureDefaultMapTileSource() }) } +func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error { + 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 { + return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err) + } + } else if err := migrator.AddColumn(&mapTileSourceRecord{}, "URLTemplateHash"); err != nil { + return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err) + } + } + + var rows []mapTileSourceRecord + if err := tx.Model(&mapTileSourceRecord{}).Where("url_template_hash = '' OR url_template_hash IS NULL").Find(&rows).Error; err != nil { + return fmt.Errorf("list map_tile_sources missing url_template_hash: %w", err) + } + for _, row := range rows { + if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", row.ID).Update("url_template_hash", mapTileSourceHash(row.URLTemplate)).Error; err != nil { + return fmt.Errorf("backfill map_tile_sources url_template_hash: %w", err) + } + } + if !migrator.HasIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash") { + if err := migrator.CreateIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash"); err != nil { + return fmt.Errorf("migrate map_tile_sources index idx_map_tile_sources_url_template_hash: %w", err) + } + } + return nil +} + func createMissingIndexes(migrator gorm.Migrator, model any, label string, indexNames []string) error { for _, indexName := range indexNames { if !migrator.HasIndex(model, indexName) { diff --git a/main.go b/main.go index 9ce5b51..72a6209 100644 --- a/main.go +++ b/main.go @@ -175,6 +175,7 @@ func parseArgs() (*config, error) { flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port") flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; empty uses host and port; unsupported on Windows") flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory") + flag.StringVar(&cfg.Web.MapTileCacheDir, "web-map-tile-cache-dir", cfg.Web.MapTileCacheDir, "Map tile disk cache root directory") flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username") flag.Parse() diff --git a/map_source_store.go b/map_source_store.go index 6177296..fd66302 100644 --- a/map_source_store.go +++ b/map_source_store.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/url" @@ -81,6 +83,14 @@ func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) { return &row, nil } +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 { + return nil, err + } + return &row, nil +} + func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) { row, err := mapTileSourceFromInput(input) if err != nil { @@ -137,13 +147,14 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi } } 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(), + "name": row.Name, + "url_template": row.URLTemplate, + "url_template_hash": row.URLTemplateHash, + "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 @@ -244,14 +255,20 @@ func (s *store) EnsureDefaultMapTileSource() error { }) } +func mapTileSourceHash(urlTemplate string) string { + h := sha256.Sum256([]byte(urlTemplate)) + return hex.EncodeToString(h[:]) +} + func defaultMapTileSourceRecord() mapTileSourceRecord { return mapTileSourceRecord{ - Name: defaultMapTileSourceName, - URLTemplate: defaultMapTileSourceURLTemplate, - Attribution: defaultMapTileSourceAttribution, - MaxZoom: defaultMapTileSourceMaxZoom, - Enabled: true, - IsDefault: true, + Name: defaultMapTileSourceName, + URLTemplate: defaultMapTileSourceURLTemplate, + URLTemplateHash: mapTileSourceHash(defaultMapTileSourceURLTemplate), + Attribution: defaultMapTileSourceAttribution, + MaxZoom: defaultMapTileSourceMaxZoom, + Enabled: true, + IsDefault: true, } } @@ -272,12 +289,13 @@ func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, err 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, + Name: name, + URLTemplate: urlTemplate, + URLTemplateHash: mapTileSourceHash(urlTemplate), + Attribution: strings.TrimSpace(input.Attribution), + MaxZoom: maxZoom, + Enabled: input.Enabled, + IsDefault: input.IsDefault, }, nil } diff --git a/map_source_store_test.go b/map_source_store_test.go index dc2a5f7..c97bad1 100644 --- a/map_source_store_test.go +++ b/map_source_store_test.go @@ -2,7 +2,10 @@ package main import ( "errors" + "strings" "testing" + + "gorm.io/gorm" ) func TestMapTileSourceDefaultSeeded(t *testing.T) { @@ -106,3 +109,113 @@ func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) { t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err) } } + +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}) + if err != nil { + t.Fatalf("CreateMapTileSource() error = %v", err) + } + want := mapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png") + if row.URLTemplateHash != want { + t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want) + } +} + +func TestMapTileSourceDefaultHasHash(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + row, err := st.GetDefaultMapTileSource() + if err != nil { + t.Fatalf("GetDefaultMapTileSource() error = %v", err) + } + want := mapTileSourceHash(defaultMapTileSourceURLTemplate) + if row.URLTemplateHash != want { + t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want) + } +} + +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}) + if err != nil { + t.Fatalf("CreateMapTileSource() error = %v", err) + } + + found, err := st.GetEnabledMapTileSourceByHash(row.URLTemplateHash) + if err != nil { + t.Fatalf("GetEnabledMapTileSourceByHash() error = %v", err) + } + if found.ID != row.ID { + t.Fatalf("found ID = %d, want %d", found.ID, row.ID) + } +} + +func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false}) + if err != nil { + t.Fatalf("CreateMapTileSource() error = %v", err) + } + + _, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("GetEnabledMapTileSourceByHash(disabled) = %v, want gorm.ErrRecordNotFound", err) + } +} + +func TestGetEnabledMapTileSourceByHashUnknown(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + _, err := st.GetEnabledMapTileSourceByHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("GetEnabledMapTileSourceByHash(unknown) = %v, want gorm.ErrRecordNotFound", err) + } +} + +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}) + 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") + } + wantPrefix := "/api/map/" + row.URLTemplateHash + "?x={x}&y={y}&z={z}" + if urlTemplate != wantPrefix { + t.Fatalf("url_template = %q, want %q", urlTemplate, wantPrefix) + } + if strings.Contains(urlTemplate, "proxy.example.com") { + t.Fatal("url_template should not contain upstream hostname") + } +} + +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") + hash3 := mapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png") + + if hash1 != hash2 { + t.Fatal("hash should be deterministic") + } + if len(hash1) != 64 { + t.Fatalf("hash length = %d, want 64", len(hash1)) + } + if hash1 == hash3 { + t.Fatal("different URLs should produce different hashes") + } +} diff --git a/map_tile_proxy_routes.go b/map_tile_proxy_routes.go new file mode 100644 index 0000000..d8fee9d --- /dev/null +++ b/map_tile_proxy_routes.go @@ -0,0 +1,200 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const ( + mapTileCacheControl = "public, max-age=86400" + maxMapTileBytes = 10 << 20 +) + +type mapTileProxy struct { + store *store + cacheDir string + client *http.Client +} + +func registerMapTileProxyRoutes(r gin.IRouter, store *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) +} diff --git a/map_tile_proxy_routes_test.go b/map_tile_proxy_routes_test.go new file mode 100644 index 0000000..d7f67f6 --- /dev/null +++ b/map_tile_proxy_routes_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +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(mapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: 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) + + 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(mapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: 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) + + 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(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) + } + + router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, 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", + } + wantStatus := []int{http.StatusBadRequest, 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(mapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: 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}) + 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) + + 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()) + } + } +} diff --git a/web.go b/web.go index 3a0d481..b288526 100644 --- a/web.go +++ b/web.go @@ -51,13 +51,13 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus r := gin.New() r.Use(gin.Logger(), gin.Recovery()) api := r.Group("/api") - registerAPIRoutes(api, store) + registerAPIRoutes(api, store, cfg.MapTileCacheDir) registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings) registerStaticRoutes(r, cfg.StaticDir) return r } -func registerAPIRoutes(r gin.IRouter, store *store) { +func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) { r.GET("/health", func(c *gin.Context) { status := gin.H{"status": "ok", "database": "ok"} if err := store.Ping(); err != nil { @@ -73,6 +73,7 @@ func registerAPIRoutes(r gin.IRouter, store *store) { registerNodeInfoRoutes(r, store, "/nodes") registerMapReportRoutes(r, store) registerMapSourceRoutes(r, store) + registerMapTileProxyRoutes(r, store, mapTileCacheDir) registerHelpRoutes(r, store) r.GET("/text-messages", func(c *gin.Context) { opts, ok := parseListOptions(c)