服务器代理地图数据

This commit is contained in:
2026-06-06 12:01:46 +08:00
parent 9d3f007cb8
commit 563d609121
10 changed files with 618 additions and 49 deletions
+5 -1
View File
@@ -155,5 +155,9 @@ func mapTileSourceDTO(row mapTileSourceRecord) gin.H {
} }
func publicMapTileSourceDTO(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}
} }
+39 -17
View File
@@ -51,12 +51,13 @@ type mysqlConfig struct {
} }
type webConfig struct { type webConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
SocketPath string `yaml:"socket_path"` SocketPath string `yaml:"socket_path"`
StaticDir string `yaml:"static_dir"` StaticDir string `yaml:"static_dir"`
Admin webAdminConfig `yaml:"admin"` MapTileCacheDir string `yaml:"map_tile_cache_dir"`
Admin webAdminConfig `yaml:"admin"`
} }
type webAdminConfig struct { type webAdminConfig struct {
@@ -104,12 +105,13 @@ type rawMySQLConfig struct {
} }
type rawWebConfig struct { type rawWebConfig struct {
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
Host *string `yaml:"host"` Host *string `yaml:"host"`
Port *int `yaml:"port"` Port *int `yaml:"port"`
SocketPath *string `yaml:"socket_path"` SocketPath *string `yaml:"socket_path"`
StaticDir *string `yaml:"static_dir"` StaticDir *string `yaml:"static_dir"`
Admin *rawWebAdminConfig `yaml:"admin"` MapTileCacheDir *string `yaml:"map_tile_cache_dir"`
Admin *rawWebAdminConfig `yaml:"admin"`
} }
type rawWebAdminConfig struct { type rawWebAdminConfig struct {
@@ -140,11 +142,12 @@ func defaultConfig() *config {
MySQL: mysqlConfig{DSN: ""}, MySQL: mysqlConfig{DSN: ""},
}, },
Web: webConfig{ Web: webConfig{
Enabled: true, Enabled: true,
Host: "0.0.0.0", Host: "0.0.0.0",
Port: 8080, Port: 8080,
SocketPath: defaultWebSocketPath(), SocketPath: defaultWebSocketPath(),
StaticDir: "./dist", StaticDir: "./dist",
MapTileCacheDir: defaultMapTileCacheDir(),
Admin: webAdminConfig{ Admin: webAdminConfig{
Username: "admin", Username: "admin",
Password: "admin", Password: "admin",
@@ -176,6 +179,17 @@ func defaultWebSocketPath() string {
return defaultWebSocketPathForGOOS(runtime.GOOS) 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 { func defaultWebSocketPathForGOOS(goos string) string {
if goos == "windows" { if goos == "windows" {
return "" return ""
@@ -342,6 +356,11 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
} else { } else {
cfg.Web.StaticDir = *raw.Web.StaticDir 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 { if raw.Web.Admin == nil {
changed = true changed = true
} else { } else {
@@ -394,6 +413,9 @@ func validateConfig(cfg *config) error {
if cfg.Web.StaticDir == "" { if cfg.Web.StaticDir == "" {
return fmt.Errorf("web.static_dir is required when web is enabled") 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 == "" { if cfg.Web.Admin.Username == "" {
return fmt.Errorf("web.admin.username is required when web is enabled") return fmt.Errorf("web.admin.username is required when web is enabled")
} }
+25 -1
View File
@@ -44,6 +44,9 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) {
if cfg.Web.StaticDir != "./dist" { if cfg.Web.StaticDir != "./dist" {
t.Fatalf("web static dir = %q, want ./dist", cfg.Web.StaticDir) 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 { if _, err := os.Stat(path); err != nil {
t.Fatalf("default config was not written: %v", err) t.Fatalf("default config was not written: %v", err)
} }
@@ -80,7 +83,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
text := string(data) 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) { if !strings.Contains(text, want) {
t.Fatalf("completed config missing %q in:\n%s", want, text) 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) { func TestDefaultWebSocketPathForGOOS(t *testing.T) {
if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" { if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" {
t.Fatalf("windows web socket path = %q, want empty", windowsPath) t.Fatalf("windows web socket path = %q, want empty", windowsPath)
@@ -228,6 +245,7 @@ func TestValidateConfigWeb(t *testing.T) {
} }
cfg = defaultConfig() cfg = defaultConfig()
cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock")
cfg.Web.Port = 0 cfg.Web.Port = 0
if err := validateConfig(cfg); err != nil { if err := validateConfig(cfg); err != nil {
t.Fatalf("web socket with invalid port error = %v, want nil", err) 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) 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 = defaultConfig()
cfg.Web.Enabled = false cfg.Web.Enabled = false
cfg.Web.Port = 0 cfg.Web.Port = 0
+41 -9
View File
@@ -116,15 +116,16 @@ func (runtimeSettingRecord) TableName() string {
} }
type mapTileSourceRecord struct { type mapTileSourceRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Name string `gorm:"column:name;not null;uniqueIndex"` Name string `gorm:"column:name;not null;uniqueIndex"`
URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"` URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"`
Attribution string `gorm:"column:attribution"` URLTemplateHash string `gorm:"column:url_template_hash;size:64;not null;uniqueIndex"`
MaxZoom int `gorm:"column:max_zoom;not null"` Attribution string `gorm:"column:attribution"`
Enabled bool `gorm:"column:enabled;not null;index"` MaxZoom int `gorm:"column:max_zoom;not null"`
IsDefault bool `gorm:"column:is_default;not null;index"` Enabled bool `gorm:"column:enabled;not null;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` IsDefault bool `gorm:"column:is_default;not null;index"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
} }
func (mapTileSourceRecord) TableName() string { func (mapTileSourceRecord) TableName() string {
@@ -463,10 +464,41 @@ func (s *store) migrate() error {
return err return err
} }
} }
if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil {
return err
}
return (&store{db: tx, driver: s.driver}).EnsureDefaultMapTileSource() 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 { func createMissingIndexes(migrator gorm.Migrator, model any, label string, indexNames []string) error {
for _, indexName := range indexNames { for _, indexName := range indexNames {
if !migrator.HasIndex(model, indexName) { if !migrator.HasIndex(model, indexName) {
+1
View File
@@ -175,6 +175,7 @@ func parseArgs() (*config, error) {
flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port") 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.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.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.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username")
flag.Parse() flag.Parse()
+37 -19
View File
@@ -1,6 +1,8 @@
package main package main
import ( import (
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
@@ -81,6 +83,14 @@ func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
return &row, nil 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) { func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) {
row, err := mapTileSourceFromInput(input) row, err := mapTileSourceFromInput(input)
if err != nil { if err != nil {
@@ -137,13 +147,14 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
} }
} }
updates := map[string]any{ updates := map[string]any{
"name": row.Name, "name": row.Name,
"url_template": row.URLTemplate, "url_template": row.URLTemplate,
"attribution": row.Attribution, "url_template_hash": row.URLTemplateHash,
"max_zoom": row.MaxZoom, "attribution": row.Attribution,
"enabled": row.Enabled, "max_zoom": row.MaxZoom,
"is_default": row.IsDefault, "enabled": row.Enabled,
"updated_at": time.Now(), "is_default": row.IsDefault,
"updated_at": time.Now(),
} }
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err 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 { func defaultMapTileSourceRecord() mapTileSourceRecord {
return mapTileSourceRecord{ return mapTileSourceRecord{
Name: defaultMapTileSourceName, Name: defaultMapTileSourceName,
URLTemplate: defaultMapTileSourceURLTemplate, URLTemplate: defaultMapTileSourceURLTemplate,
Attribution: defaultMapTileSourceAttribution, URLTemplateHash: mapTileSourceHash(defaultMapTileSourceURLTemplate),
MaxZoom: defaultMapTileSourceMaxZoom, Attribution: defaultMapTileSourceAttribution,
Enabled: true, MaxZoom: defaultMapTileSourceMaxZoom,
IsDefault: true, 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 nil, fmt.Errorf("max zoom must be between 1 and 30")
} }
return &mapTileSourceRecord{ return &mapTileSourceRecord{
Name: name, Name: name,
URLTemplate: urlTemplate, URLTemplate: urlTemplate,
Attribution: strings.TrimSpace(input.Attribution), URLTemplateHash: mapTileSourceHash(urlTemplate),
MaxZoom: maxZoom, Attribution: strings.TrimSpace(input.Attribution),
Enabled: input.Enabled, MaxZoom: maxZoom,
IsDefault: input.IsDefault, Enabled: input.Enabled,
IsDefault: input.IsDefault,
}, nil }, nil
} }
+113
View File
@@ -2,7 +2,10 @@ package main
import ( import (
"errors" "errors"
"strings"
"testing" "testing"
"gorm.io/gorm"
) )
func TestMapTileSourceDefaultSeeded(t *testing.T) { func TestMapTileSourceDefaultSeeded(t *testing.T) {
@@ -106,3 +109,113 @@ func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err) 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")
}
}
+200
View File
@@ -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)
}
+154
View File
@@ -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())
}
}
}
+3 -2
View File
@@ -51,13 +51,13 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus
r := gin.New() r := gin.New()
r.Use(gin.Logger(), gin.Recovery()) r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api") api := r.Group("/api")
registerAPIRoutes(api, store) registerAPIRoutes(api, store, cfg.MapTileCacheDir)
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings) registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings)
registerStaticRoutes(r, cfg.StaticDir) registerStaticRoutes(r, cfg.StaticDir)
return r 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) { r.GET("/health", func(c *gin.Context) {
status := gin.H{"status": "ok", "database": "ok"} status := gin.H{"status": "ok", "database": "ok"}
if err := store.Ping(); err != nil { if err := store.Ping(); err != nil {
@@ -73,6 +73,7 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
registerNodeInfoRoutes(r, store, "/nodes") registerNodeInfoRoutes(r, store, "/nodes")
registerMapReportRoutes(r, store) registerMapReportRoutes(r, store)
registerMapSourceRoutes(r, store) registerMapSourceRoutes(r, store)
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
registerHelpRoutes(r, store) registerHelpRoutes(r, store)
r.GET("/text-messages", func(c *gin.Context) { r.GET("/text-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := parseListOptions(c)