├── handlers ├── templates │ ├── static │ │ ├── .nvmrc │ │ ├── index.js │ │ ├── package.json │ │ └── package-lock.json │ └── map.html ├── static.go ├── id.go ├── static_test.go ├── templates.go ├── request.go ├── id_test.go ├── blankpng_test.go ├── blankpng.go ├── tile.go ├── middleware.go ├── tile_test.go ├── serviceset.go ├── tileset.go └── arcgis.go ├── .dockerignore ├── testdata ├── world_cities.mbtiles ├── geography-class-jpg.mbtiles ├── geography-class-png.mbtiles ├── invalid-tile-format.mbtiles ├── geography-class-webp.mbtiles ├── geography-class-png-no-bounds.mbtiles ├── world_cities_missing_center.mbtiles └── invalid.mbtiles ├── .github ├── actions │ ├── build_arm64 │ │ ├── action.yml │ │ ├── Dockerfile │ │ └── entrypoint.sh │ └── build_x86_64 │ │ ├── action.yml │ │ ├── Dockerfile │ │ └── entrypoint.sh └── workflows │ ├── release.yml │ ├── codeql-analysis.yml │ ├── docker.yml │ └── test.yml ├── .gitignore ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── go.mod ├── go.sum ├── .all-contributorsrc ├── watch.go ├── CHANGELOG.md ├── main.go └── README.md /handlers/templates/static/.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | **/node_modules 3 | testdata/* 4 | testdata-bad/* -------------------------------------------------------------------------------- /testdata/world_cities.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/consbio/mbtileserver/HEAD/testdata/world_cities.mbtiles -------------------------------------------------------------------------------- /.github/actions/build_arm64/action.yml: -------------------------------------------------------------------------------- 1 | name: "build_arm64" 2 | runs: 3 | using: "docker" 4 | image: "Dockerfile" 5 | -------------------------------------------------------------------------------- /.github/actions/build_x86_64/action.yml: -------------------------------------------------------------------------------- 1 | name: "build_x86_64" 2 | runs: 3 | using: "docker" 4 | image: "Dockerfile" 5 | -------------------------------------------------------------------------------- /testdata/geography-class-jpg.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/consbio/mbtileserver/HEAD/testdata/geography-class-jpg.mbtiles -------------------------------------------------------------------------------- /testdata/geography-class-png.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/consbio/mbtileserver/HEAD/testdata/geography-class-png.mbtiles -------------------------------------------------------------------------------- /testdata/invalid-tile-format.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/consbio/mbtileserver/HEAD/testdata/invalid-tile-format.mbtiles -------------------------------------------------------------------------------- /testdata/geography-class-webp.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/consbio/mbtileserver/HEAD/testdata/geography-class-webp.mbtiles -------------------------------------------------------------------------------- /testdata/geography-class-png-no-bounds.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/consbio/mbtileserver/HEAD/testdata/geography-class-png-no-bounds.mbtiles -------------------------------------------------------------------------------- /testdata/world_cities_missing_center.mbtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/consbio/mbtileserver/HEAD/testdata/world_cities_missing_center.mbtiles -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tilesets/* 2 | .idea/* 3 | .DS_Store 4 | main 5 | templates/static/node_modules/* 6 | mbtileserver 7 | *.pem 8 | .certs/* 9 | **/node_modules/* 10 | coverage* -------------------------------------------------------------------------------- /handlers/templates/static/index.js: -------------------------------------------------------------------------------- 1 | window.maplibregl = require("maplibre-gl/dist/maplibre-gl.js"); 2 | window.geoViewport = require('@mapbox/geo-viewport') 3 | 4 | require("maplibre-gl/dist/maplibre-gl.css"); 5 | -------------------------------------------------------------------------------- /.github/actions/build_x86_64/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-bookworm 2 | 3 | RUN \ 4 | apt-get update && \ 5 | apt-get install -y ca-certificates openssl zip curl jq gcc-multilib \ 6 | g++-multilib && \ 7 | update-ca-certificates && \ 8 | rm -rf /var/lib/apt 9 | 10 | COPY entrypoint.sh /entrypoint.sh 11 | 12 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | mbtileserver: 5 | image: ghcr.io/consbio/mbtileserver:latest 6 | container_name: mbtileserver 7 | entrypoint: /mbtileserver --enable-reload-signal 8 | restart: always 9 | ports: 10 | - 8080:8000 11 | volumes: 12 | - ./mbtiles/testdata:/tilesets 13 | -------------------------------------------------------------------------------- /.github/actions/build_arm64/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-bookworm 2 | 3 | RUN \ 4 | dpkg --add-architecture arm64 && \ 5 | apt-get update && \ 6 | apt-get install -y ca-certificates openssl zip curl jq \ 7 | gcc-12-aarch64-linux-gnu gcc-aarch64-linux-gnu libsqlite3-dev:arm64 && \ 8 | update-ca-certificates && \ 9 | rm -rf /var/lib/apt 10 | 11 | COPY entrypoint.sh /entrypoint.sh 12 | 13 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /handlers/static.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | ) 8 | 9 | //go:embed templates/static/dist 10 | var staticAssets embed.FS 11 | 12 | // staticHandler returns a handler that retrieves static files from the virtual 13 | // assets filesystem based on a path. The URL prefix of the resource where 14 | // these are accessed is first trimmed before requesting from the filesystem. 15 | func staticHandler(prefix string) http.Handler { 16 | assetsFS, _ := fs.Sub(staticAssets, "templates/static/dist") 17 | return http.StripPrefix(prefix, http.FileServer(http.FS(assetsFS))) 18 | } 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: compile mbtileserver 2 | FROM golang:1.23-alpine3.20 3 | 4 | WORKDIR / 5 | RUN apk add git build-base 6 | COPY . . 7 | 8 | RUN GOOS=linux go build -o /mbtileserver 9 | 10 | 11 | # Stage 2: start from a smaller image 12 | FROM alpine:3.20 13 | 14 | WORKDIR / 15 | 16 | # Link libs to get around issues using musl 17 | RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 18 | 19 | # copy the executable to the empty container 20 | COPY --from=0 /mbtileserver /mbtileserver 21 | 22 | # Set the command as the entrypoint, so that it captures any 23 | # command-line arguments passed in 24 | ENTRYPOINT ["/mbtileserver"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2024, Conservation Biology Institute 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Go Binaries for Release 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | build_x86_64: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Make binaries 14 | uses: ./.github/actions/build_x86_64 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | 18 | build_arm64: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Make binaries 24 | uses: ./.github/actions/build_arm64 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /handlers/id.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/base64" 6 | "path/filepath" 7 | ) 8 | 9 | type IDGenerator func(filename, baseDir string) (string, error) 10 | 11 | // SHA1ID generates a URL safe base64 encoded SHA1 hash of the filename. 12 | func SHA1ID(filename string) string { 13 | // generate IDs from hash of full file path 14 | filenameHash := sha1.Sum([]byte(filename)) 15 | return base64.RawURLEncoding.EncodeToString(filenameHash[:]) 16 | } 17 | 18 | // RelativePathID returns a relative path from the basedir to the filename. 19 | func RelativePathID(filename, baseDir string) (string, error) { 20 | subpath, err := filepath.Rel(baseDir, filename) 21 | if err != nil { 22 | return "", err 23 | } 24 | subpath = filepath.ToSlash(subpath) 25 | return subpath[:len(subpath)-len(filepath.Ext(filename))], nil 26 | } 27 | -------------------------------------------------------------------------------- /handlers/templates/static/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mbtileserver", 3 | "version": "0.11.0", 4 | "description": "Go powered mbtiles tile map server", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "node_modules/.bin/esbuild --bundle --minify --loader:.png=base64 --outdir=dist index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/consbio/mbtileserver.git" 12 | }, 13 | "keywords": [ 14 | "map", 15 | "tiles", 16 | "mbtiles" 17 | ], 18 | "author": "Brendan C. Ward", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/consbio/mbtileserver/issues" 22 | }, 23 | "homepage": "https://github.com/consbio/mbtileserver", 24 | "dependencies": { 25 | "@mapbox/geo-viewport": "^0.5.0", 26 | "esbuild": "^0.24.0", 27 | "maplibre-gl": "^4.7.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /handlers/static_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "io/fs" 5 | "testing" 6 | ) 7 | 8 | func Test_StaticAssets(t *testing.T) { 9 | root := "templates/static/dist" 10 | assetsFS, _ := fs.Sub(staticAssets, root) 11 | 12 | expected := []string{ 13 | "index.js", 14 | "index.css", 15 | } 16 | 17 | // verify that expected files are present in the embedded filesystem 18 | for _, filename := range expected { 19 | if _, err := fs.ReadFile(assetsFS, filename); err != nil { 20 | t.Error("Could not find expected file in embedded filesystem:", root+filename) 21 | continue 22 | } 23 | } 24 | 25 | // verify that only these files are present 26 | entries, err := fs.ReadDir(assetsFS, ".") 27 | if err != nil { 28 | t.Error("Could not list files in embedded filesystem:", root) 29 | } 30 | if len(entries) != len(expected) { 31 | t.Errorf("Got unexpected number of entries in embedded filesystem (%s): %v, expected: %v", root, len(entries), len(expected)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /testdata/invalid.mbtiles: -------------------------------------------------------------------------------- 1 | SQLite format 3@ .Z  -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/consbio/mbtileserver 2 | 3 | require ( 4 | github.com/brendan-ward/mbtiles-go v0.2.0 5 | github.com/evalphobia/logrus_sentry v0.8.2 6 | github.com/fsnotify/fsnotify v1.8.0 7 | github.com/labstack/echo/v4 v4.13.3 8 | github.com/sirupsen/logrus v1.9.3 9 | github.com/spf13/cobra v1.8.1 10 | golang.org/x/crypto v0.31.0 11 | ) 12 | 13 | require ( 14 | crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c // indirect 15 | github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect 16 | github.com/getsentry/raven-go v0.2.0 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/labstack/gommon v0.4.2 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/pkg/errors v0.9.1 // indirect 22 | github.com/spf13/pflag v1.0.5 // indirect 23 | github.com/valyala/bytebufferpool v1.0.0 // indirect 24 | github.com/valyala/fasttemplate v1.2.2 // indirect 25 | golang.org/x/net v0.33.0 // indirect 26 | golang.org/x/sys v0.28.0 // indirect 27 | golang.org/x/text v0.21.0 // indirect 28 | golang.org/x/time v0.8.0 // indirect 29 | ) 30 | 31 | go 1.21 32 | -------------------------------------------------------------------------------- /handlers/templates.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | "io/fs" 10 | "net/http" 11 | ) 12 | 13 | //go:embed templates/*.html 14 | var templateAssets embed.FS 15 | 16 | var templates *template.Template 17 | 18 | func init() { 19 | // load templates 20 | templatesFS, err := fs.Sub(templateAssets, "templates") 21 | if err != nil { 22 | fmt.Errorf("Error getting embedded path for templates: %w", err) 23 | panic(err) 24 | } 25 | 26 | t, err := template.ParseFS(templatesFS, "map.html") 27 | if err != nil { 28 | fmt.Errorf("Could not resolve template: %w", err) 29 | panic(err) 30 | } 31 | templates = t 32 | } 33 | 34 | // executeTemplates first tries to find the template with the given name for 35 | // the ServiceSet. If that fails because it is not available, an HTTP status 36 | // Internal Server Error is returned. 37 | func executeTemplate(w http.ResponseWriter, name string, data interface{}) (int, error) { 38 | t := templates.Lookup(name) 39 | if t == nil { 40 | return http.StatusInternalServerError, fmt.Errorf("template not found %q", name) 41 | } 42 | buf := &bytes.Buffer{} 43 | err := t.Execute(buf, data) 44 | if err != nil { 45 | return http.StatusInternalServerError, err 46 | } 47 | _, err = io.Copy(w, buf) 48 | return http.StatusOK, err 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: "43 1 * * 6" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | go: [1.23.x] 24 | 25 | steps: 26 | - name: Install GCC (Ubuntu) 27 | run: | 28 | sudo apt update 29 | sudo apt install -y gcc-multilib g++-multilib 30 | shell: bash 31 | 32 | - name: Install Go 33 | uses: actions/setup-go@v4 34 | with: 35 | go-version: ${{ matrix.go }} 36 | 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v2 42 | with: 43 | languages: go 44 | 45 | - name: Build 46 | run: go build . 47 | 48 | - name: Perform CodeQL Analysis 49 | uses: github/codeql-action/analyze@v2 50 | -------------------------------------------------------------------------------- /handlers/request.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // scheme returns the underlying URL scheme of the original request. 9 | func scheme(r *http.Request) string { 10 | if r.TLS != nil { 11 | return "https" 12 | } 13 | if scheme := r.Header.Get("X-Forwarded-Proto"); scheme != "" { 14 | return scheme 15 | } 16 | if scheme := r.Header.Get("X-Forwarded-Protocol"); scheme != "" { 17 | return scheme 18 | } 19 | if ssl := r.Header.Get("X-Forwarded-Ssl"); ssl == "on" { 20 | return "https" 21 | } 22 | if scheme := r.Header.Get("X-Url-Scheme"); scheme != "" { 23 | return scheme 24 | } 25 | return "http" 26 | } 27 | 28 | type handlerFunc func(http.ResponseWriter, *http.Request) (int, error) 29 | 30 | // wrapJSONP writes b (JSON marshalled to bytes) as a JSONP response to 31 | // w if the callback query parameter is present, and writes b as a JSON 32 | // response otherwise. Any error that occurs during writing is returned. 33 | func wrapJSONP(w http.ResponseWriter, r *http.Request, b []byte) (err error) { 34 | callback := r.URL.Query().Get("callback") 35 | 36 | if callback != "" { 37 | w.Header().Set("Content-Type", "application/javascript") 38 | _, err = w.Write([]byte(fmt.Sprintf("%s(%s);", callback, b))) 39 | return 40 | } 41 | 42 | w.Header().Set("Content-Type", "application/json") 43 | _, err = w.Write(b) 44 | return 45 | } 46 | -------------------------------------------------------------------------------- /handlers/id_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "testing" 4 | 5 | func Test_SHA1ID(t *testing.T) { 6 | tests := []struct { 7 | path string 8 | id string 9 | }{ 10 | {path: "geography-class-jpg.mbtiles", id: "TL6FA75kn46-zpifOfLsLesTLrY"}, 11 | {path: "geography-class-png.mbtiles", id: "8YAiWw7-AjNp9pPppb-ksNM1RMg"}, 12 | {path: "geography-class-webp.mbtiles", id: "-8JT6wT5OSPbXvbONZGCVWOQb1g"}, 13 | {path: "world_cities.mbtiles", id: "L7qsx7KIKNf96L3KKXovAsWV4uE"}, 14 | } 15 | 16 | for _, tc := range tests { 17 | filename := "./testdata/" + tc.path 18 | id := SHA1ID(filename) 19 | 20 | if id != tc.id { 21 | t.Error("SHA1ID:", id, "not expected value:", tc.id, "for path:", filename) 22 | continue 23 | } 24 | } 25 | } 26 | 27 | func Test_RelativePathID(t *testing.T) { 28 | tests := []struct { 29 | path string 30 | id string 31 | }{ 32 | {path: "geography-class-jpg.mbtiles", id: "geography-class-jpg"}, 33 | {path: "geography-class-png.mbtiles", id: "geography-class-png"}, 34 | {path: "geography-class-webp.mbtiles", id: "geography-class-webp"}, 35 | {path: "world_cities.mbtiles", id: "world_cities"}, 36 | } 37 | 38 | for _, tc := range tests { 39 | filename := "./testdata/" + tc.path 40 | id, err := RelativePathID(filename, "./testdata") 41 | if err != nil { 42 | t.Error("Could not create RelativePathID for path:", filename) 43 | continue 44 | } 45 | 46 | if id != tc.id { 47 | t.Error("RelativePathID:", id, "not expected value:", tc.id, "for path:", filename) 48 | continue 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /handlers/blankpng_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/hex" 5 | "testing" 6 | ) 7 | 8 | func Test_BlankPNG(t *testing.T) { 9 | tests := []struct { 10 | tilesize uint32 11 | data string 12 | }{ 13 | { 14 | // tilesize not detected, defaults to 256 15 | tilesize: 0, data: "89504e470d0a1a0a0000000d494844520000010000000100010300000066bc3a2500000003504c5445000000a77a3dda0000000174524e530040e6d8660000001f4944415468deedc1010d000000c220fba736c737600000000000000000710721000001a75729d70000000049454e44ae426082", 16 | }, 17 | { 18 | // not available, defaults to 256 19 | tilesize: 128, data: "89504e470d0a1a0a0000000d494844520000010000000100010300000066bc3a2500000003504c5445000000a77a3dda0000000174524e530040e6d8660000001f4944415468deedc1010d000000c220fba736c737600000000000000000710721000001a75729d70000000049454e44ae426082", 20 | }, 21 | { 22 | tilesize: 256, data: "89504e470d0a1a0a0000000d494844520000010000000100010300000066bc3a2500000003504c5445000000a77a3dda0000000174524e530040e6d8660000001f4944415468deedc1010d000000c220fba736c737600000000000000000710721000001a75729d70000000049454e44ae426082", 23 | }, 24 | { 25 | tilesize: 512, data: "89504e470d0a1a0a0000000d4948445200000200000002000103000000ceb646b900000003504c5445000000a77a3dda0000000174524e530040e6d866000000364944415478daedc1010100000082a0feaf6e88c00000000000000000000000000000000000000000000000000000000000000000e20e8200000101f5375e0000000049454e44ae426082", 26 | }, 27 | } 28 | 29 | for _, tc := range tests { 30 | blankPNG := hex.EncodeToString(BlankPNG(tc.tilesize)) 31 | if blankPNG != tc.data { 32 | t.Error("BlankPNG returned unexpected value for size ", tc.tilesize, " :", blankPNG) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /handlers/blankpng.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | // BlankPNG returns bytes of a blank PNG of the requested size, falling back to 4 | // 256px if a suitable size is not available. 5 | // Used for the request handlers to return when an image tile is not available. 6 | // Images created with https://png-pixel.com/ then minified using tinypng.com 7 | func BlankPNG(tilesize uint32) []byte { 8 | switch tilesize { 9 | case 512: 10 | return []byte{ 11 | 0x89, 0x50, 0x4e, 0x47, 0xd, 0xa, 0x1a, 0xa, 0x0, 0x0, 0x0, 0xd, 12 | 0x49, 0x48, 0x44, 0x52, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x2, 0x0, 0x1, 13 | 0x3, 0x0, 0x0, 0x0, 0xce, 0xb6, 0x46, 0xb9, 0x0, 0x0, 0x0, 0x3, 0x50, 14 | 0x4c, 0x54, 0x45, 0x0, 0x0, 0x0, 0xa7, 0x7a, 0x3d, 0xda, 0x0, 0x0, 15 | 0x0, 0x1, 0x74, 0x52, 0x4e, 0x53, 0x0, 0x40, 0xe6, 0xd8, 0x66, 0x0, 16 | 0x0, 0x0, 0x36, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0xed, 0xc1, 0x1, 17 | 0x1, 0x0, 0x0, 0x0, 0x82, 0xa0, 0xfe, 0xaf, 0x6e, 0x88, 0xc0, 0x0, 18 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 19 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 20 | 0x0, 0x0, 0x0, 0xe2, 0xe, 0x82, 0x0, 0x0, 0x1, 0x1, 0xf5, 0x37, 0x5e, 21 | 0x0, 0x0, 0x0, 0x0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 22 | } 23 | default: // 256 24 | return []byte{ 25 | 0x89, 0x50, 0x4e, 0x47, 0xd, 0xa, 0x1a, 0xa, 0x0, 0x0, 0x0, 0xd, 26 | 0x49, 0x48, 0x44, 0x52, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x1, 0x0, 0x1, 27 | 0x3, 0x0, 0x0, 0x0, 0x66, 0xbc, 0x3a, 0x25, 0x0, 0x0, 0x0, 0x3, 0x50, 28 | 0x4c, 0x54, 0x45, 0x0, 0x0, 0x0, 0xa7, 0x7a, 0x3d, 0xda, 0x0, 0x0, 29 | 0x0, 0x1, 0x74, 0x52, 0x4e, 0x53, 0x0, 0x40, 0xe6, 0xd8, 0x66, 0x0, 30 | 0x0, 0x0, 0x1f, 0x49, 0x44, 0x41, 0x54, 0x68, 0xde, 0xed, 0xc1, 0x1, 31 | 0xd, 0x0, 0x0, 0x0, 0xc2, 0x20, 0xfb, 0xa7, 0x36, 0xc7, 0x37, 0x60, 32 | 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x71, 0x7, 0x21, 0x0, 0x0, 33 | 0x1, 0xa7, 0x57, 0x29, 0xd7, 0x0, 0x0, 0x0, 0x0, 0x49, 0x45, 0x4e, 34 | 0x44, 0xae, 0x42, 0x60, 0x82, 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /handlers/tile.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | earthRadius = 6378137.0 12 | earthCircumference = math.Pi * earthRadius 13 | initialResolution = 2 * earthCircumference / 256 14 | dpi uint8 = 96 15 | ) 16 | 17 | type tileCoord struct { 18 | z, x, y int64 19 | } 20 | 21 | // tileCoordFromString parses and returns tileCoord coordinates and an optional 22 | // extension from the three parameters. The parameter z is interpreted as the 23 | // web mercator zoom level, it is supposed to be an unsigned integer that will 24 | // fit into 8 bit. The parameters x and y are interpreted as longitude and 25 | // latitude tile indices for that zoom level, both are supposed be integers in 26 | // the integer interval [0,2^z). Additionally, y may also have an optional 27 | // filename extension (e.g. "42.png") which is removed before parsing the 28 | // number, and returned, too. In case an error occurred during parsing or if the 29 | // values are not in the expected interval, the returned error is non-nil. 30 | func tileCoordFromString(z, x, y string) (tc tileCoord, ext string, err error) { 31 | if tc.z, err = strconv.ParseInt(z, 10, 64); err != nil { 32 | err = fmt.Errorf("cannot parse zoom level: %v", err) 33 | return 34 | } 35 | const ( 36 | errMsgParse = "cannot parse %s coordinate axis: %v" 37 | errMsgOOB = "%s coordinate (%d) is out of bounds for zoom level %d" 38 | ) 39 | if tc.x, err = strconv.ParseInt(x, 10, 64); err != nil { 40 | err = fmt.Errorf(errMsgParse, "x", err) 41 | return 42 | } 43 | if tc.x >= (1 << tc.z) { 44 | err = fmt.Errorf(errMsgOOB, "x", tc.x, tc.z) 45 | return 46 | } 47 | s := y 48 | if l := strings.LastIndex(s, "."); l >= 0 { 49 | s, ext = s[:l], s[l:] 50 | } 51 | if tc.y, err = strconv.ParseInt(s, 10, 64); err != nil { 52 | err = fmt.Errorf(errMsgParse, "y", err) 53 | return 54 | } 55 | if tc.y >= (1 << tc.z) { 56 | err = fmt.Errorf(errMsgOOB, "y", tc.y, tc.z) 57 | return 58 | } 59 | return 60 | } 61 | 62 | func calcScaleResolution(zoomLevel int, dpi uint8) (float64, float64) { 63 | var denom = 1 << zoomLevel 64 | resolution := initialResolution / float64(denom) 65 | scale := float64(dpi) * 39.37 * resolution // 39.37 in/m 66 | return scale, resolution 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker images 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "v*.*.*" 9 | pull_request: 10 | paths: 11 | - ".github/workflows/docker.yml" 12 | - "Dockerfile" 13 | - "go.sum" 14 | workflow_dispatch: 15 | 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | # disable unknown/unknown arch from showing up in ghcr.io 20 | 21 | jobs: 22 | buildx: 23 | runs-on: ubuntu-latest 24 | permissions: 25 | contents: read 26 | packages: write 27 | concurrency: 28 | # cancel jobs on PRs only 29 | group: ${{ github.workflow }}-${{ github.ref }} 30 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v3 36 | with: 37 | platforms: "linux/amd64,linux/arm64" 38 | - name: Set up Docker Buildx 39 | uses: docker/setup-buildx-action@v3 40 | id: buildx 41 | with: 42 | install: true 43 | 44 | - name: Docker meta 45 | id: meta 46 | uses: docker/metadata-action@v5 47 | with: 48 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 49 | tags: | 50 | type=raw,value=latest,enable={{is_default_branch}} 51 | type=semver,pattern={{version}} 52 | type=ref,event=pr 53 | 54 | - name: Login to Github Container Registry 55 | if: github.event_name != 'pull_request' 56 | uses: docker/login-action@v3 57 | with: 58 | registry: ${{ env.REGISTRY }} 59 | username: ${{ github.actor }} 60 | password: ${{ secrets.GITHUB_TOKEN }} 61 | 62 | - name: Build and push 63 | uses: docker/build-push-action@v5 64 | with: 65 | context: . 66 | platforms: linux/amd64,linux/arm64 67 | provenance: false 68 | push: ${{ github.event_name != 'pull_request' }} 69 | tags: ${{ steps.meta.outputs.tags }} 70 | labels: ${{ steps.meta.outputs.labels }} 71 | -------------------------------------------------------------------------------- /.github/actions/build_arm64/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Adapted from: 4 | # https://sosedoff.com/2019/02/12/go-github-actions.html 5 | # https://github.com/ngs/go-release.action/blob/master/entrypoint.sh 6 | 7 | set -e 8 | 9 | # Build targets 10 | targets=${@-"linux/arm64"} 11 | 12 | 13 | # Get repo information from the github event 14 | EVENT_DATA=$(cat $GITHUB_EVENT_PATH) 15 | # echo $EVENT_DATA | jq . 16 | UPLOAD_URL=$(echo $EVENT_DATA | jq -r .release.upload_url) 17 | UPLOAD_URL=${UPLOAD_URL/\{?name,label\}/} 18 | RELEASE_NAME=$(echo $EVENT_DATA | jq -r .release.tag_name) 19 | 20 | # Validate token. 21 | curl -o /dev/null -sH "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY" || { echo "Error: Invalid token or network issue!"; exit 1; } 22 | 23 | tag=$(basename $GITHUB_REF) 24 | 25 | if [[ -z "$GITHUB_WORKSPACE" ]]; then 26 | echo "Set the GITHUB_WORKSPACE env variable." 27 | exit 1 28 | fi 29 | 30 | if [[ -z "$GITHUB_REPOSITORY" ]]; then 31 | echo "Set the GITHUB_REPOSITORY env variable." 32 | exit 1 33 | fi 34 | 35 | root_path="/go/src/github.com/$GITHUB_REPOSITORY" 36 | release_path="$GITHUB_WORKSPACE/.release" 37 | repo_name="$(echo $GITHUB_REPOSITORY | cut -d '/' -f2)" 38 | 39 | 40 | echo "----> Setting up Go repository" 41 | mkdir -p $release_path 42 | mkdir -p $root_path 43 | cp -a $GITHUB_WORKSPACE/* $root_path/ 44 | cd $root_path 45 | 46 | 47 | for target in $targets; do 48 | os="$(echo $target | cut -d '/' -f1)" 49 | arch="$(echo $target | cut -d '/' -f2)" 50 | 51 | output="${release_path}/${repo_name}_${tag}_${os}_${arch}" 52 | 53 | echo "----> Building project for: $target" 54 | GOOS=$os GOARCH=$arch CGO_ENABLED=1 CC="/usr/bin/aarch64-linux-gnu-gcc" go build -o "$output" 55 | zip -j $output.zip "$output" > /dev/null 56 | done 57 | 58 | echo "----> Build is complete. List of files at $release_path:" 59 | cd $release_path 60 | ls -al 61 | 62 | 63 | # Upload to github release assets 64 | for asset in "${release_path}"/*.zip; do 65 | file_name="$(basename "$asset")" 66 | 67 | status_code="$(curl -sS -X POST \ 68 | --write-out "%{http_code}" -o "/tmp/$file_name.json" \ 69 | -H "Authorization: token $GITHUB_TOKEN" \ 70 | -H "Content-Length: $(stat -c %s "$asset")" \ 71 | -H "Content-Type: application/zip" \ 72 | --upload-file "$asset" \ 73 | "$UPLOAD_URL?name=$file_name")" 74 | 75 | if [ "$status_code" -ne "201" ]; then 76 | >&2 printf "\n\tERR: Failed asset upload: %s\n" "$file_name" 77 | >&2 jq . < "/tmp/$file_name.json" 78 | exit 1 79 | fi 80 | done 81 | 82 | echo "----> Upload is complete" -------------------------------------------------------------------------------- /.github/actions/build_x86_64/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Adapted from: 4 | # https://sosedoff.com/2019/02/12/go-github-actions.html 5 | # https://github.com/ngs/go-release.action/blob/master/entrypoint.sh 6 | 7 | set -e 8 | 9 | # Build targets 10 | # Omit: darwin/amd64 darwin/386 (MacOS requires signed, notarized binaries now) 11 | # Omit: windows/amd64 windows/386 (CGO cross-compile for Windows is more work) 12 | targets=${@-"linux/amd64 linux/386"} 13 | 14 | 15 | # Get repo information from the github event 16 | EVENT_DATA=$(cat $GITHUB_EVENT_PATH) 17 | # echo $EVENT_DATA | jq . 18 | UPLOAD_URL=$(echo $EVENT_DATA | jq -r .release.upload_url) 19 | UPLOAD_URL=${UPLOAD_URL/\{?name,label\}/} 20 | RELEASE_NAME=$(echo $EVENT_DATA | jq -r .release.tag_name) 21 | 22 | # Validate token. 23 | curl -o /dev/null -sH "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY" || { echo "Error: Invalid token or network issue!"; exit 1; } 24 | 25 | tag=$(basename $GITHUB_REF) 26 | 27 | if [[ -z "$GITHUB_WORKSPACE" ]]; then 28 | echo "Set the GITHUB_WORKSPACE env variable." 29 | exit 1 30 | fi 31 | 32 | if [[ -z "$GITHUB_REPOSITORY" ]]; then 33 | echo "Set the GITHUB_REPOSITORY env variable." 34 | exit 1 35 | fi 36 | 37 | root_path="/go/src/github.com/$GITHUB_REPOSITORY" 38 | release_path="$GITHUB_WORKSPACE/.release" 39 | repo_name="$(echo $GITHUB_REPOSITORY | cut -d '/' -f2)" 40 | 41 | 42 | echo "----> Setting up Go repository" 43 | mkdir -p $release_path 44 | mkdir -p $root_path 45 | cp -a $GITHUB_WORKSPACE/* $root_path/ 46 | cd $root_path 47 | 48 | for target in $targets; do 49 | os="$(echo $target | cut -d '/' -f1)" 50 | arch="$(echo $target | cut -d '/' -f2)" 51 | 52 | output="${release_path}/${repo_name}_${tag}_${os}_${arch}" 53 | 54 | echo "----> Building project for: $target" 55 | GOOS=$os GOARCH=$arch CGO_ENABLED=1 go build -o "$output" 56 | zip -j $output.zip "$output" > /dev/null 57 | done 58 | 59 | echo "----> Build is complete. List of files at $release_path:" 60 | cd $release_path 61 | ls -al 62 | 63 | 64 | # Upload to github release assets 65 | for asset in "${release_path}"/*.zip; do 66 | file_name="$(basename "$asset")" 67 | 68 | status_code="$(curl -sS -X POST \ 69 | --write-out "%{http_code}" -o "/tmp/$file_name.json" \ 70 | -H "Authorization: token $GITHUB_TOKEN" \ 71 | -H "Content-Length: $(stat -c %s "$asset")" \ 72 | -H "Content-Type: application/zip" \ 73 | --upload-file "$asset" \ 74 | "$UPLOAD_URL?name=$file_name")" 75 | 76 | if [ "$status_code" -ne "201" ]; then 77 | >&2 printf "\n\tERR: Failed asset upload: %s\n" "$file_name" 78 | >&2 jq . < "/tmp/$file_name.json" 79 | exit 1 80 | fi 81 | done 82 | 83 | echo "----> Upload is complete" -------------------------------------------------------------------------------- /handlers/middleware.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha1" 6 | "crypto/subtle" 7 | "encoding/base64" 8 | "fmt" 9 | "net/http" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // HandlerWrapper is a type definition for a function that takes an http.Handler 15 | // and returns an http.Handler 16 | type HandlerWrapper func(http.Handler) http.Handler 17 | 18 | // maxSignatureAge defines the maximum amount of time, in seconds 19 | // that an HMAC signature can remain valid 20 | const maxSignatureAge = time.Duration(15) * time.Minute 21 | 22 | // HMACAuthMiddleware wraps incoming requests to enforce HMAC signature authorization. 23 | // All requests are expected to have either "signature" and "date" query parameters 24 | // or "X-Signature" and "X-Signature-Date" headers. 25 | func HMACAuthMiddleware(secretKey string, serviceSet *ServiceSet) HandlerWrapper { 26 | return func(next http.Handler) http.Handler { 27 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | 29 | query := r.URL.Query() 30 | 31 | rawSignature := query.Get("signature") 32 | if rawSignature == "" { 33 | rawSignature = r.Header.Get("X-Signature") 34 | } 35 | if rawSignature == "" { 36 | http.Error(w, "No signature provided", http.StatusUnauthorized) 37 | return 38 | } 39 | 40 | rawSignDate := query.Get("date") 41 | if rawSignDate == "" { 42 | rawSignDate = r.Header.Get("X-Signature-Date") 43 | } 44 | if rawSignDate == "" { 45 | http.Error(w, "No signature date provided", http.StatusUnauthorized) 46 | return 47 | } 48 | 49 | signDate, err := time.Parse(time.RFC3339Nano, rawSignDate) 50 | if err != nil { 51 | http.Error(w, "Signature date is not valid RFC3339", http.StatusBadRequest) 52 | return 53 | } 54 | if time.Now().Sub(signDate) > maxSignatureAge { 55 | http.Error(w, "Signature is expired", http.StatusUnauthorized) 56 | return 57 | } 58 | 59 | signatureParts := strings.SplitN(rawSignature, ":", 2) 60 | if len(signatureParts) != 2 { 61 | http.Error(w, "Signature does not contain salt", http.StatusBadRequest) 62 | return 63 | } 64 | salt, signature := signatureParts[0], signatureParts[1] 65 | 66 | tilesetID := serviceSet.IDFromURLPath(r.URL.Path) 67 | 68 | key := sha1.New() 69 | key.Write([]byte(salt + secretKey)) 70 | hash := hmac.New(sha1.New, key.Sum(nil)) 71 | message := fmt.Sprintf("%s:%s", rawSignDate, tilesetID) 72 | hash.Write([]byte(message)) 73 | checkSignature := base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) 74 | 75 | if subtle.ConstantTimeCompare([]byte(signature), []byte(checkSignature)) != 1 { 76 | // Signature is not valid for the requested resource 77 | // either tilesetID does not match in the signature, or date 78 | http.Error(w, "Signature not authorized for resource", http.StatusUnauthorized) 79 | return 80 | } 81 | 82 | next.ServeHTTP(w, r) 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /handlers/tile_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "math" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func Test_TileCoordFromString(t *testing.T) { 10 | tests := []struct { 11 | path string 12 | ext string 13 | z int64 14 | x int64 15 | y int64 16 | }{ 17 | {path: "0/0/0", ext: "", z: 0, x: 0, y: 0}, 18 | {path: "10/1/2", ext: "", z: 10, x: 1, y: 2}, 19 | {path: "0/0/0.png", ext: ".png", z: 0, x: 0, y: 0}, 20 | {path: "0/0/0.jpg", ext: ".jpg", z: 0, x: 0, y: 0}, 21 | {path: "0/0/0.webp", ext: ".webp", z: 0, x: 0, y: 0}, 22 | {path: "0/0/0.pbf", ext: ".pbf", z: 0, x: 0, y: 0}, 23 | } 24 | 25 | for _, tc := range tests { 26 | // split the tile path for easier testing 27 | pcs := strings.Split(tc.path, "/") 28 | l := len(pcs) 29 | zIn, xIn, yIn := pcs[l-3], pcs[l-2], pcs[l-1] 30 | 31 | coord, ext, err := tileCoordFromString(zIn, xIn, yIn) 32 | if err != nil { 33 | t.Error("Could not extract tile coordinate from tile path:", tc.path, err) 34 | continue 35 | } 36 | 37 | if ext != tc.ext { 38 | t.Error("tileCoordFromString returned unexpected extension:", ext, "expected:", tc.ext) 39 | continue 40 | } 41 | if coord.z != tc.z { 42 | t.Error("tileCoordFromString returned unexpected z:", coord.z, "expected:", tc.z) 43 | continue 44 | } 45 | if coord.x != tc.x { 46 | t.Error("tileCoordFromString returned unexpected x:", coord.x, "expected:", tc.x) 47 | continue 48 | } 49 | if coord.y != tc.y { 50 | t.Error("tileCoordFromString returned unexpected y:", coord.y, "expected:", tc.y) 51 | continue 52 | } 53 | } 54 | } 55 | 56 | func Test_TileCoordFromString_Invalid(t *testing.T) { 57 | tests := []struct { 58 | path string 59 | err string 60 | }{ 61 | {path: "0/1/2", err: "out of bounds for zoom level"}, 62 | {path: "0/0/a", err: "cannot parse y coordinate"}, 63 | {path: "0/0/a.png", err: "cannot parse y coordinate"}, 64 | {path: "0/0/0.foo.bar", err: "cannot parse y coordinate"}, 65 | {path: "a/0/0", err: "cannot parse zoom level"}, 66 | {path: "0/a/0", err: "cannot parse x coordinate"}, 67 | } 68 | 69 | for _, tc := range tests { 70 | // split the tile path for easier testing 71 | pcs := strings.Split(tc.path, "/") 72 | l := len(pcs) 73 | zIn, xIn, yIn := pcs[l-3], pcs[l-2], pcs[l-1] 74 | 75 | _, _, err := tileCoordFromString(zIn, xIn, yIn) 76 | if err == nil { 77 | t.Error("tileCoordFromString did not raise expected error for invalid tile path:", tc.path) 78 | continue 79 | } 80 | if !strings.Contains(err.Error(), tc.err) { 81 | t.Error("tileCoordFromString returned unexpected error message:", err, "expected:", tc.err) 82 | continue 83 | } 84 | } 85 | } 86 | 87 | func Test_CalcScaleResolution(t *testing.T) { 88 | zoom0resolution := (2 * earthCircumference / 256) / (1 << 0) 89 | zoom2resolution := (2 * earthCircumference / 256) / (1 << 2) 90 | 91 | tests := []struct { 92 | zoom int 93 | dpi uint8 94 | scale float64 95 | resolution float64 96 | }{ 97 | {zoom: 0, dpi: 0, scale: 0, resolution: zoom0resolution}, 98 | {zoom: 0, dpi: 100, scale: 100 * 39.37 * zoom0resolution, resolution: zoom0resolution}, 99 | {zoom: 2, dpi: 0, scale: 0, resolution: zoom2resolution}, 100 | {zoom: 2, dpi: 100, scale: 100 * 39.37 * zoom2resolution, resolution: zoom2resolution}, 101 | } 102 | 103 | tolerance := 1e-7 104 | 105 | for _, tc := range tests { 106 | scale, resolution := calcScaleResolution(tc.zoom, tc.dpi) 107 | if math.Abs(resolution-tc.resolution) > tolerance { 108 | t.Error("calcScaleResolution returned unexpected resolution:", resolution, "expected:", tc.resolution) 109 | continue 110 | } 111 | if math.Abs(scale-tc.scale) > tolerance { 112 | t.Error("calcScaleResolution returned unexpected scale:", scale, "expected:", tc.scale) 113 | continue 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go: [1.21.x, 1.22.x, 1.23.x] 15 | os: [ubuntu-latest, ubuntu-22.04, macos-latest] 16 | runs-on: ${{ matrix.os }} 17 | env: 18 | CGO_ENABLED: 1 19 | steps: 20 | - name: Install GCC (MacOS) 21 | if: startsWith(matrix.os, 'macos') 22 | run: | 23 | brew update 24 | brew install gcc 25 | - name: Install GCC (Ubuntu) 26 | if: startsWith(matrix.os, 'ubuntu') 27 | run: | 28 | sudo apt update 29 | sudo apt install -y gcc-multilib g++-multilib 30 | shell: bash 31 | - name: Install Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: ${{ matrix.go }} 35 | - name: Checkout code 36 | uses: actions/checkout@v4 37 | - name: Test 38 | run: go test -v ./... 39 | 40 | test-arm64: 41 | strategy: 42 | matrix: 43 | go: [1.21.x, 1.22.x, 1.23.x] 44 | 45 | runs-on: ubuntu-latest 46 | env: 47 | CGO_ENABLED: 1 48 | GOOS: linux 49 | GOARCH: arm64 50 | CC: "/usr/bin/aarch64-linux-gnu-gcc-13" 51 | steps: 52 | # Update sources to split out amd64 vs arm64 since arm64 is not supported on all mirrors 53 | # adaped from https://github.com/shamil-mubarakshin/tests-repository/blob/main/.github/workflows/run-ubuntu-matrix.yml 54 | - name: Update sources for arm64 55 | shell: bash 56 | run: | 57 | sudo dpkg --add-architecture arm64 58 | cat < deb822sources 59 | Types: deb 60 | URIs: http://archive.ubuntu.com/ubuntu/ 61 | Suites: noble noble-updates 62 | Components: main restricted universe 63 | Architectures: amd64 64 | 65 | Types: deb 66 | URIs: http://security.ubuntu.com/ubuntu/ 67 | Suites: noble-security 68 | Components: main restricted universe 69 | Architectures: amd64 70 | 71 | Types: deb 72 | URIs: http://azure.ports.ubuntu.com/ubuntu-ports/ 73 | Suites: noble noble-updates 74 | Components: main restricted multiverse universe 75 | Architectures: arm64 76 | 77 | EOF 78 | 79 | sudo mv deb822sources /etc/apt/sources.list.d/ubuntu.sources 80 | 81 | - name: Install GCC and SQLite for Arm64 82 | shell: bash 83 | run: | 84 | sudo apt-get update 85 | DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ 86 | gcc-13-aarch64-linux-gnu \ 87 | libsqlite3-dev:arm64 \ 88 | file 89 | - name: Install Go 90 | uses: actions/setup-go@v5 91 | with: 92 | go-version: ${{ matrix.go }} 93 | - name: Checkout code 94 | uses: actions/checkout@v4 95 | - name: Build 96 | run: go build -v . 97 | shell: bash 98 | - name: Verify build 99 | run: file mbtileserver 100 | shell: bash 101 | # NOTE: we can't test an arm64 binary on amd64 host 102 | 103 | coverage: 104 | runs-on: ubuntu-latest 105 | steps: 106 | - name: Install GCC (Ubuntu) 107 | run: | 108 | sudo apt update 109 | sudo apt install -y gcc-multilib g++-multilib 110 | shell: bash 111 | - name: Install Go 112 | if: success() 113 | uses: actions/setup-go@v4 114 | with: 115 | go-version: 1.23.x 116 | - name: Checkout code 117 | uses: actions/checkout@v4 118 | - name: Calc coverage 119 | run: | 120 | go test -v -covermode=count -coverprofile=coverage.out ./... 121 | - name: Convert coverage.out to coverage.lcov 122 | uses: jandelgado/gcov2lcov-action@v1.0.9 123 | - name: Coveralls 124 | uses: coverallsapp/github-action@v2 125 | with: 126 | github-token: ${{ secrets.github_token }} 127 | path-to-lcov: coverage.lcov 128 | -------------------------------------------------------------------------------- /handlers/templates/map.html: -------------------------------------------------------------------------------- 1 | {{ define "map" }} 2 | 3 | 4 | 5 | 6 | {{.ID}} Preview 7 | 11 | 12 | 13 | 26 | 27 | 28 | 29 |
30 | 164 | 165 | 166 | {{ end }} 167 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797 h1:yDf7ARQc637HoxDho7xjqdvO5ZA2Yb+xzv/fOnnvZzw= 2 | crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= 3 | crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c h1:wvzox0eLO6CKQAMcOqz7oH3UFqMpMmK7kwmwV+22HIs= 4 | crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= 5 | github.com/brendan-ward/mbtiles-go v0.2.0 h1:jtSbOrmkMEOUD1kY02UIuKHjE9sR4nevQaMRlHmz0bU= 6 | github.com/brendan-ward/mbtiles-go v0.2.0/go.mod h1:dzvwuJtq2SurdmdLtkgGO4Zmhb49zmVO9gGyPUxBYc0= 7 | github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s= 8 | github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/evalphobia/logrus_sentry v0.8.2 h1:dotxHq+YLZsT1Bb45bB5UQbfCh3gM/nFFetyN46VoDQ= 14 | github.com/evalphobia/logrus_sentry v0.8.2/go.mod h1:pKcp+vriitUqu9KiWj/VRFbRfFNUwz95/UkgG8a6MNc= 15 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 16 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 17 | github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= 18 | github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= 19 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 20 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 21 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 22 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 23 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 24 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 25 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 26 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 27 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 31 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 35 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 36 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 37 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 38 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 39 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 40 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 43 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 44 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 45 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 46 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 47 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 48 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 49 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 50 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 51 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 52 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 53 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 57 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 59 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 60 | golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 61 | golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 63 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 65 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 66 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "mbtileserver", 3 | "projectOwner": "consbio", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "brendan-ward", 15 | "name": "Brendan Ward", 16 | "avatar_url": "https://avatars2.githubusercontent.com/u/3375604?v=4", 17 | "profile": "https://astutespruce.com", 18 | "contributions": [ 19 | "code", 20 | "doc", 21 | "bug", 22 | "blog", 23 | "review", 24 | "ideas" 25 | ] 26 | }, 27 | { 28 | "login": "fawick", 29 | "name": "Fabian Wickborn", 30 | "avatar_url": "https://avatars3.githubusercontent.com/u/1886500?v=4", 31 | "profile": "https://github.com/fawick", 32 | "contributions": [ 33 | "code", 34 | "doc", 35 | "bug", 36 | "ideas" 37 | ] 38 | }, 39 | { 40 | "login": "nikmolnar", 41 | "name": "Nik Molnar", 42 | "avatar_url": "https://avatars1.githubusercontent.com/u/2422416?v=4", 43 | "profile": "https://github.com/nikmolnar", 44 | "contributions": [ 45 | "code", 46 | "ideas", 47 | "bug" 48 | ] 49 | }, 50 | { 51 | "login": "sikmir", 52 | "name": "Nikolay Korotkiy", 53 | "avatar_url": "https://avatars3.githubusercontent.com/u/688044?v=4", 54 | "profile": "https://sikmir.ru", 55 | "contributions": [ 56 | "code", 57 | "bug" 58 | ] 59 | }, 60 | { 61 | "login": "retbrown", 62 | "name": "Robert Brown", 63 | "avatar_url": "https://avatars1.githubusercontent.com/u/3111954?v=4", 64 | "profile": "https://github.com/retbrown", 65 | "contributions": [ 66 | "code" 67 | ] 68 | }, 69 | { 70 | "login": "mika-go-13", 71 | "name": "Mihail", 72 | "avatar_url": "https://avatars.githubusercontent.com/u/26978815?v=4", 73 | "profile": "mika-go-13", 74 | "contributions": [ 75 | "code" 76 | ] 77 | }, 78 | { 79 | "login": "buma", 80 | "name": "Marko Burjek", 81 | "avatar_url": "https://avatars2.githubusercontent.com/u/1055967?v=4", 82 | "profile": "https://github.com/buma", 83 | "contributions": [ 84 | "code" 85 | ] 86 | }, 87 | { 88 | "login": "Krizz", 89 | "name": "Kristjan", 90 | "avatar_url": "https://avatars0.githubusercontent.com/u/689050?v=4", 91 | "profile": "https://github.com/Krizz", 92 | "contributions": [ 93 | "code" 94 | ] 95 | }, 96 | { 97 | "login": "evbarnett", 98 | "name": "evbarnett", 99 | "avatar_url": "https://avatars2.githubusercontent.com/u/4960874?v=4", 100 | "profile": "https://github.com/evbarnett", 101 | "contributions": [ 102 | "bug" 103 | ] 104 | }, 105 | { 106 | "login": "carlos-mg89", 107 | "name": "walkaholic.me", 108 | "avatar_url": "https://avatars1.githubusercontent.com/u/19690868?v=4", 109 | "profile": "https://www.walkaholic.me", 110 | "contributions": [ 111 | "bug" 112 | ] 113 | }, 114 | { 115 | "login": "brianvoe", 116 | "name": "Brian Voelker", 117 | "avatar_url": "https://avatars1.githubusercontent.com/u/1580910?v=4", 118 | "profile": "http://www.webiswhatido.com", 119 | "contributions": [ 120 | "bug" 121 | ] 122 | }, 123 | { 124 | "login": "schorsch", 125 | "name": "Georg Leciejewski", 126 | "avatar_url": "https://avatars1.githubusercontent.com/u/13575?v=4", 127 | "profile": "http://salesking.eu", 128 | "contributions": [ 129 | "bug" 130 | ] 131 | }, 132 | { 133 | "login": "cbenz", 134 | "name": "Christophe Benz", 135 | "avatar_url": "https://avatars.githubusercontent.com/u/12686?v=4", 136 | "profile": "https://github.com/cbenz", 137 | "contributions": [ 138 | "bug" 139 | ] 140 | }, 141 | { 142 | "login": "reyemtm", 143 | "name": "Malcolm Meyer", 144 | "avatar_url": "https://avatars.githubusercontent.com/u/6398929?v=4", 145 | "profile": "https://github.com/reyemtm", 146 | "contributions": [ 147 | "bug" 148 | ] 149 | }, 150 | { 151 | "login": "jleedev", 152 | "name": "Josh Lee", 153 | "avatar_url": "https://avatars.githubusercontent.com/u/23022?v=4", 154 | "profile": "https://github.com/jleedev", 155 | "contributions": [ 156 | "code" 157 | ] 158 | }, 159 | { 160 | "login": "Martin8412", 161 | "name": "Martin Karlsen Jensen", 162 | "avatar_url": "https://avatars.githubusercontent.com/u/2369612?v=4", 163 | "profile": "https://github.com/Martin8412", 164 | "contributions": [ 165 | "code" 166 | ] 167 | } 168 | ], 169 | "contributorsPerLine": 7 170 | } 171 | -------------------------------------------------------------------------------- /watch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | mbtiles "github.com/brendan-ward/mbtiles-go" 11 | "github.com/consbio/mbtileserver/handlers" 12 | "github.com/fsnotify/fsnotify" 13 | ) 14 | 15 | // debounce debounces requests to a callback function to occur no more 16 | // frequently than interval; once this is reached, the callback is called. 17 | // 18 | // Unique values sent to the channel are stored in an internal map and all 19 | // are processed once the the interval is up. 20 | func debounce(interval time.Duration, input chan string, exit chan struct{}, firstCallback func(arg string), callback func(arg string)) { 21 | // keep a log of unique paths 22 | var items = make(map[string]bool) 23 | var item string 24 | timer := time.NewTimer(interval) 25 | for { 26 | select { 27 | case item = <-input: 28 | if _, ok := items[item]; !ok { 29 | // first time we see a given path, we need to call lockHandler 30 | // to lock it (unlocked by callback) 31 | firstCallback(item) 32 | } 33 | items[item] = true 34 | timer.Reset(interval) 35 | case <-timer.C: 36 | for path := range items { 37 | callback(path) 38 | delete(items, path) 39 | } 40 | case <-exit: 41 | return 42 | } 43 | } 44 | } 45 | 46 | // FSWatcher provides a filesystem watcher to detect when mbtiles files are 47 | // created, updated, or removed on the filesystem. 48 | type FSWatcher struct { 49 | watcher *fsnotify.Watcher 50 | svcSet *handlers.ServiceSet 51 | generateID handlers.IDGenerator 52 | } 53 | 54 | // NewFSWatcher creates a new FSWatcher to watch the filesystem for changes to 55 | // mbtiles files and updates the ServiceSet accordingly. 56 | // 57 | // The generateID function needs to be of the same type used when the tilesets 58 | // were originally added to the ServiceSet. 59 | func NewFSWatcher(svcSet *handlers.ServiceSet, generateID handlers.IDGenerator) (*FSWatcher, error) { 60 | watcher, err := fsnotify.NewWatcher() 61 | if err != nil { 62 | return nil, err 63 | } 64 | return &FSWatcher{ 65 | watcher: watcher, 66 | svcSet: svcSet, 67 | generateID: generateID, 68 | }, nil 69 | } 70 | 71 | // Close closes the FSWatcher and stops watching the filesystem. 72 | func (w *FSWatcher) Close() { 73 | if w.watcher != nil { 74 | w.watcher.Close() 75 | } 76 | } 77 | 78 | // WatchDir sets up the filesystem watcher for baseDir and all existing subdirectories 79 | func (w *FSWatcher) WatchDir(baseDir string) error { 80 | c := make(chan string) 81 | exit := make(chan struct{}) 82 | // debounced call to create / update tileset 83 | go debounce(500*time.Millisecond, c, exit, func(path string) { 84 | // callback for first time path is debounced 85 | id, err := w.generateID(path, baseDir) 86 | if err != nil { 87 | log.Errorf("Could not create ID for tileset %q\n%v", path, err) 88 | return 89 | } 90 | // lock tileset for writing, if it exists 91 | w.svcSet.LockTileset(id) 92 | }, func(path string) { 93 | // callback after debouncing incoming requests 94 | 95 | // Verify that file can be opened with mbtiles-go, which runs 96 | // validation on open. 97 | // If file cannot be opened, assume it is still being written / copied. 98 | db, err := mbtiles.Open(path) 99 | if err != nil { 100 | return 101 | } 102 | db.Close() 103 | 104 | // determine file ID for tileset 105 | id, err := w.generateID(path, baseDir) 106 | if err != nil { 107 | log.Errorf("Could not create ID for tileset %q\n%v", path, err) 108 | return 109 | } 110 | 111 | // update existing tileset 112 | if w.svcSet.HasTileset(id) { 113 | err = w.svcSet.UpdateTileset(id) 114 | if err != nil { 115 | log.Errorf("Could not update tileset %q with ID %q\n%v", path, id, err) 116 | } else { 117 | // only unlock if successfully updated 118 | w.svcSet.UnlockTileset(id) 119 | log.Infof("Updated tileset %q with ID %q\n", path, id) 120 | } 121 | return 122 | } 123 | 124 | // create new tileset 125 | err = w.svcSet.AddTileset(path, id) 126 | if err != nil { 127 | log.Errorf("Could not add tileset for %q with ID %q\n%v", path, id, err) 128 | } else { 129 | log.Infof("Updated tileset %q with ID %q\n", path, id) 130 | } 131 | return 132 | }) 133 | go func() { 134 | for { 135 | select { 136 | case event, ok := <-w.watcher.Events: 137 | if !ok { 138 | log.Errorf("error in filewatcher for %q, exiting filewatcher", event.Name) 139 | return 140 | } 141 | 142 | if !((event.Op&fsnotify.Create == fsnotify.Create) || 143 | (event.Op&fsnotify.Write == fsnotify.Write) || 144 | (event.Op&fsnotify.Remove == fsnotify.Remove) || 145 | (event.Op&fsnotify.Rename == fsnotify.Rename)) { 146 | continue 147 | } 148 | 149 | path := event.Name 150 | if ext := filepath.Ext(path); ext == "" { 151 | if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write || 152 | event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename { 153 | 154 | // NOTE: we cannot distinguish which incoming event paths 155 | // correspond to directory events or file events, so we 156 | // trigger a reload in all cases 157 | 158 | exit <- struct{}{} 159 | err := w.WatchDir(baseDir) 160 | if err != nil { 161 | return 162 | } 163 | log.Info("Reload watch dir on directory change") 164 | return 165 | } else { 166 | continue 167 | } 168 | 169 | } 170 | 171 | if _, err := os.Stat(path + "-journal"); err == nil { 172 | // Don't try to load .mbtiles files that are being written 173 | log.Debugf("Tileset %q is currently being created or is incomplete\n", path) 174 | continue 175 | } 176 | 177 | if (event.Op&fsnotify.Create == fsnotify.Create) || 178 | (event.Op&fsnotify.Write == fsnotify.Write) { 179 | // This event may get called multiple times while a file is being copied into a watched directory, 180 | // so we debounce this instead. 181 | c <- path 182 | continue 183 | } 184 | 185 | if (event.Op&fsnotify.Remove == fsnotify.Remove) || (event.Op&fsnotify.Rename == fsnotify.Rename) { 186 | // some file move events trigger remove / rename, so if the file still exists, assume it is 187 | // one of these 188 | _, err := os.Stat(path) 189 | if err == nil { 190 | // debounce to give it a little more time to update, if needed 191 | c <- path 192 | continue 193 | } 194 | 195 | // remove tileset immediately so that there are not other errors in request handlers 196 | id, err := w.generateID(path, baseDir) 197 | if err != nil { 198 | log.Errorf("Could not create ID for tileset %q\n%v", path, err) 199 | } 200 | if w.svcSet.HasTileset(id) { 201 | err = w.svcSet.RemoveTileset(id) 202 | if err != nil { 203 | log.Errorf("Could not remove tileset %q with ID %q\n%v", path, id, err) 204 | } else { 205 | log.Infof("Removed tileset %q with ID %q\n", path, id) 206 | } 207 | } 208 | } 209 | 210 | case err, ok := <-w.watcher.Errors: 211 | if !ok { 212 | log.Errorf("error in filewatcher, exiting filewatcher") 213 | return 214 | } 215 | log.Error(err) 216 | } 217 | } 218 | }() 219 | 220 | err := filepath.Walk(baseDir, 221 | func(path string, info os.FileInfo, err error) error { 222 | if err != nil { 223 | return err 224 | } 225 | if info.Mode().IsDir() { 226 | return w.watcher.Add(path) 227 | } 228 | return nil 229 | }) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | return nil 235 | } 236 | 237 | func exists(path string) bool { 238 | _, err := os.Stat(path) 239 | if err != nil { 240 | return false 241 | } 242 | return true 243 | } 244 | -------------------------------------------------------------------------------- /handlers/serviceset.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/url" 10 | "sort" 11 | "strings" 12 | ) 13 | 14 | // ServiceSetConfig provides configuration options for a ServiceSet 15 | type ServiceSetConfig struct { 16 | EnableServiceList bool 17 | EnableTileJSON bool 18 | EnablePreview bool 19 | EnableArcGIS bool 20 | BasemapStyleURL string 21 | BasemapTilesURL string 22 | ReturnMissingImageTile404 bool 23 | RootURL *url.URL 24 | ErrorWriter io.Writer 25 | } 26 | 27 | // ServiceSet is a group of tilesets plus configuration options. 28 | // It provides access to all tilesets from a root URL. 29 | type ServiceSet struct { 30 | tilesets map[string]*Tileset 31 | 32 | enableServiceList bool 33 | enableTileJSON bool 34 | enablePreview bool 35 | enableArcGIS bool 36 | basemapStyleURL string 37 | basemapTilesURL string 38 | returnMissingImageTile404 bool 39 | 40 | rootURL *url.URL 41 | errorWriter io.Writer 42 | } 43 | 44 | // New returns a new ServiceSet. 45 | // If no ServiceSetConfig is provided, the service is initialized with default 46 | // values of ServiceSetConfig. 47 | func New(cfg *ServiceSetConfig) (*ServiceSet, error) { 48 | if cfg == nil { 49 | cfg = &ServiceSetConfig{} 50 | } 51 | 52 | s := &ServiceSet{ 53 | tilesets: make(map[string]*Tileset), 54 | enableServiceList: cfg.EnableServiceList, 55 | enableTileJSON: cfg.EnableTileJSON, 56 | enablePreview: cfg.EnablePreview, 57 | enableArcGIS: cfg.EnableArcGIS, 58 | basemapStyleURL: cfg.BasemapStyleURL, 59 | basemapTilesURL: cfg.BasemapTilesURL, 60 | returnMissingImageTile404: cfg.ReturnMissingImageTile404, 61 | rootURL: cfg.RootURL, 62 | errorWriter: cfg.ErrorWriter, 63 | } 64 | 65 | return s, nil 66 | } 67 | 68 | // AddTileset adds a single tileset identified by idGenerator using the filename. 69 | // If a service already exists with that ID, an error is returned. 70 | func (s *ServiceSet) AddTileset(filename, id string) error { 71 | if _, ok := s.tilesets[id]; ok { 72 | return fmt.Errorf("Tileset already exists for ID: %q", id) 73 | } 74 | 75 | path := s.rootURL.Path + "/" + id 76 | ts, err := newTileset(s, filename, id, path) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | s.tilesets[id] = ts 82 | 83 | return nil 84 | } 85 | 86 | // UpdateTileset reloads the Tileset identified by id, if it already exists. 87 | // Otherwise, this returns an error. 88 | // Any errors encountered updating the Tileset are returned. 89 | func (s *ServiceSet) UpdateTileset(id string) error { 90 | ts, ok := s.tilesets[id] 91 | if !ok { 92 | return fmt.Errorf("Tileset does not exist with ID: %q", id) 93 | } 94 | 95 | err := ts.reload() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | 103 | // RemoveTileset removes the Tileset and closes the associated mbtiles file 104 | // identified by id, if it already exists. 105 | // If it does not exist, this returns without error. 106 | // Any errors encountered removing the Tileset are returned. 107 | func (s *ServiceSet) RemoveTileset(id string) error { 108 | ts, ok := s.tilesets[id] 109 | if !ok { 110 | return nil 111 | } 112 | 113 | err := ts.delete() 114 | if err != nil { 115 | return err 116 | } 117 | 118 | // remove from tilesets and router 119 | delete(s.tilesets, id) 120 | 121 | return nil 122 | } 123 | 124 | // LockTileset sets a write mutex on the tileset to block reads while this 125 | // tileset is being updated. 126 | // This is ignored if the tileset does not exist. 127 | func (s *ServiceSet) LockTileset(id string) { 128 | ts, ok := s.tilesets[id] 129 | if !ok || ts == nil { 130 | return 131 | } 132 | 133 | ts.locked = true 134 | } 135 | 136 | // UnlockTileset removes the write mutex on the tileset. 137 | // This is ignored if the tileset does not exist. 138 | func (s *ServiceSet) UnlockTileset(id string) { 139 | ts, ok := s.tilesets[id] 140 | if !ok || ts == nil { 141 | return 142 | } 143 | 144 | ts.locked = false 145 | } 146 | 147 | // HasTileset returns true if the tileset identified by id exists within this 148 | // ServiceSet. 149 | func (s *ServiceSet) HasTileset(id string) bool { 150 | if _, ok := s.tilesets[id]; ok { 151 | return true 152 | } 153 | return false 154 | } 155 | 156 | // Size returns the number of tilesets in this ServiceSet 157 | func (s *ServiceSet) Size() int { 158 | return len(s.tilesets) 159 | } 160 | 161 | // ServiceInfo provides basic information about the service. 162 | type ServiceInfo struct { 163 | ImageType string `json:"imageType"` 164 | URL string `json:"url"` 165 | Name string `json:"name"` 166 | } 167 | 168 | // logError writes to the configured ServiceSet.errorWriter if available 169 | // or the standard logger otherwise. 170 | func (s *ServiceSet) logError(format string, args ...interface{}) { 171 | if s.errorWriter != nil { 172 | s.errorWriter.Write([]byte(fmt.Sprintf(format, args...))) 173 | } else { 174 | log.Printf(format, args...) 175 | } 176 | } 177 | 178 | // serviceListHandler is an http.HandlerFunc that provides a listing of all 179 | // published services in this ServiceSet 180 | func (s *ServiceSet) serviceListHandler(w http.ResponseWriter, r *http.Request) { 181 | rootURL := fmt.Sprintf("%s://%s%s", scheme(r), getRequestHost(r), r.URL) 182 | services := []ServiceInfo{} 183 | 184 | // sort ids alpabetically 185 | var ids []string 186 | for id := range s.tilesets { 187 | ids = append(ids, id) 188 | } 189 | sort.Strings(ids) 190 | 191 | for _, id := range ids { 192 | ts := s.tilesets[id] 193 | services = append(services, ServiceInfo{ 194 | ImageType: ts.tileFormatString(), 195 | URL: fmt.Sprintf("%s/%s", rootURL, id), 196 | Name: ts.name, 197 | }) 198 | } 199 | bytes, err := json.Marshal(services) 200 | if err != nil { 201 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 202 | s.logError("Error marshalling service list JSON for %v: %v", r.URL.Path, err) 203 | return 204 | } 205 | w.Header().Set("Content-Type", "application/json") 206 | _, err = w.Write(bytes) 207 | 208 | if err != nil { 209 | s.logError("Error writing service list content: %v", err) 210 | } 211 | } 212 | 213 | // tilesetHandler is an http.HandlerFunc that handles a given tileset 214 | // and associated subpaths 215 | func (s *ServiceSet) tilesetHandler(w http.ResponseWriter, r *http.Request) { 216 | id := s.IDFromURLPath((r.URL.Path)) 217 | 218 | if id == "" { 219 | http.Error(w, "404 page not found", http.StatusNotFound) 220 | return 221 | } 222 | 223 | s.tilesets[id].router.ServeHTTP(w, r) 224 | } 225 | 226 | // IDFromURLPath extracts a tileset ID from a URL Path. 227 | // If no valid ID is found, a blank string is returned. 228 | func (s *ServiceSet) IDFromURLPath(id string) string { 229 | root := s.rootURL.Path + "/" 230 | 231 | if strings.HasPrefix(id, root) { 232 | id = strings.TrimPrefix(id, root) 233 | 234 | // test exact match first 235 | if _, ok := s.tilesets[id]; ok { 236 | return id 237 | } 238 | 239 | // Split on /tiles/ and /map/ and trim /map 240 | i := strings.LastIndex(id, "/tiles/") 241 | if i != -1 { 242 | id = id[:i] 243 | } else if s.enablePreview { 244 | id = strings.TrimSuffix(id, "/map") 245 | 246 | i = strings.LastIndex(id, "/map/") 247 | if i != -1 { 248 | id = id[:i] 249 | } 250 | } 251 | } else if s.enableArcGIS && strings.HasPrefix(id, ArcGISServicesRoot) { 252 | id = strings.TrimPrefix(id, ArcGISServicesRoot) 253 | // MapServer should be a reserved word, so should be OK to split on it 254 | id = strings.Split(id, "/MapServer")[0] 255 | } else { 256 | // not on a subpath of service roots, so no id 257 | return "" 258 | } 259 | 260 | // make sure tileset exists 261 | if _, ok := s.tilesets[id]; ok { 262 | return id 263 | } 264 | 265 | return "" 266 | } 267 | 268 | // Handler returns a http.Handler that serves the endpoints of the ServiceSet. 269 | // The function ef is called with any occurring error if it is non-nil, so it 270 | // can be used for e.g. logging with logging facilities of the caller. 271 | func (s *ServiceSet) Handler() http.Handler { 272 | m := http.NewServeMux() 273 | 274 | root := s.rootURL.Path + "/" 275 | 276 | // Route requests at the tileset or subpath to the corresponding tileset 277 | m.HandleFunc(root, s.tilesetHandler) 278 | 279 | if s.enableServiceList { 280 | m.HandleFunc(s.rootURL.Path, s.serviceListHandler) 281 | } else { 282 | m.Handle(s.rootURL.Path, http.NotFoundHandler()) 283 | } 284 | 285 | if s.enableArcGIS { 286 | m.HandleFunc(ArcGISInfoRoot, s.arcgisInfoHandler) 287 | m.HandleFunc(ArcGISServicesRoot, s.tilesetHandler) 288 | } else { 289 | m.Handle(ArcGISRoot, http.NotFoundHandler()) 290 | } 291 | 292 | return m 293 | } 294 | 295 | // getRequestHost returns the value of the X-Forwarded-Host header if set, otherwise 296 | // the request Host value 297 | func getRequestHost(r *http.Request) string { 298 | host := r.Header.Get("X-Forwarded-Host") 299 | if host != "" { 300 | return host 301 | } 302 | return r.Host 303 | } 304 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.11.0 4 | 5 | - support returning missing image tiles as HTTP 404 instead of blank tiles using 6 | the `--missing-image-tile-404` option (#177). 7 | - added ability to detect directory changes to `--enable-fs-watch` (#156). 8 | 9 | ### Breaking changes 10 | - now requires Go 1.21+ (per Go version policy). 11 | 12 | ## 0.10.0 13 | 14 | - supports GCC11 on Ubuntu 22.04 (#166) 15 | - switch from Docker Hub to Github Container Registry (#168) 16 | 17 | ### Breaking changes 18 | 19 | - now requires Go 1.18+. 20 | - replaced Leaflet and Mapbox GL JS maps used in preview endpoint with MapLibre GL. 21 | This change drops the interactive map controls present in the Leaflet preview 22 | (opacity slider, basemap changer, zoombox) (#176). 23 | - removed built-in basemaps used in the preview endpoint (#176). You can now 24 | specify a basemap style using the `--basemap-style-url` option or basemap 25 | image tiles using the `--basemap-tiles-url` option. 26 | 27 | ## 0.9.0 28 | 29 | ### Breaking changes 30 | 31 | - now requires Go 1.17+. 32 | 33 | ### General changes 34 | 35 | - upgraded Docker containers to Go 1.19 36 | - upgraded Go version used for release to Go 1.19 37 | 38 | ### Command-line interface 39 | 40 | - added support for specifying host IP address to listen on using the `--host` 41 | option (#138). 42 | - switched basemaps to [Stamen map tiles](http://maps.stamen.com/) (#148) 43 | - added auto-detection of tile size and return blank tile of same size 44 | (if available) for image tilesets when tile is not found (#155). 45 | 46 | ## 0.8.2 47 | 48 | ### Bug fixes 49 | 50 | - fixed handling of `X-Forwarded-Host` header (#135) 51 | - fixed incorrect closing of sqlite connections during initial validation 52 | of tilesets (#136) in `mbtiles-go` 53 | - handle missing `center` in `metadata` table in map preview for vector tiles (#137) 54 | 55 | ## 0.8.1 56 | 57 | ### Bug fixes 58 | 59 | - fixed handling of moved / renamed files within watched directories when 60 | using the `--enable-fs-watch` option. 61 | 62 | ## 0.8 63 | 64 | ### General changes 65 | 66 | - display attribution in preview maps if present in tileset metadata. 67 | - upgraded Docker containers to Go 1.17. 68 | - upgraded Go version used for release to Go 1.17. 69 | - switched to go:embed for embedding templates and static assets. 70 | - dropped internal mbtiles package in favor of github.com/brendan-ward/mbtiles-go, 71 | which wraps the SQlite-specific go package `crawshaw.io/sqlite` for better 72 | performance. 73 | 74 | ### Command-line interface 75 | 76 | - added support for watching filesystem for changes to tilesets using 77 | `--enable-fs-watch` option. 78 | 79 | ### Breaking changes 80 | 81 | - now requires Go 1.16+. 82 | - removes ArcGIS API layer info at the service root and layers endpoint (#116); 83 | this was not providing useful information for image tilesets. 84 | - removed `handlers.Assets`; static assets are intended only for use in template 85 | or static file handlers. 86 | - removed support for UTF Grids. 87 | 88 | ### Bug Fixes 89 | 90 | - fix handlers for ArcGIS API endpoints, resolving tile shift issue (#116). 91 | - obviated incorrect include of node_modules in compiled asset file; executable 92 | is now smaller and faster to build. 93 | 94 | ## O.7 95 | 96 | ### General changes 97 | 98 | - substantial changes to internal functionality and HTTP handlers, see details below 99 | - now requires Go 1.13+ 100 | - upgraded Docker containers to Go 1.16 101 | - upgraded Go version used for release to Go 1.16 102 | - removed `vendor` directory; no longer needed for Go 1.13 103 | - switched from Travis-CI to Github actions for running tests 104 | 105 | ### Command-line interface 106 | 107 | - added support for automatically generating unique tileset IDs using `--generate-ids` option 108 | - added ability to toggle off non-tile endpoints: 109 | 110 | - `--disable-preview`: disables the map preview, enabled by default. 111 | - `--disable-svc-list`: disables the list of map services, enabled by default 112 | - `--disable-tilejson`: disables the TileJSON endpoint for each tile service 113 | - `--tiles-only`: shortcut that disables preview, service list, and TileJSON endpoints 114 | 115 | - added ability to have multiple tile paths using a comma-delimited list of paths passed to `--dir` option 116 | 117 | - moved static assets for map preview that were originally served on `/static` 118 | endpoint to `/services//map/static` so that this endpoint is 119 | disabled when preview is disabled via `--disable-preview`. 120 | 121 | ### Go API 122 | 123 | - added `ServiceSetConfig` for configuration options for `ServiceSet` instances 124 | - added `ServiceSet.AddTileset()`, `ServiceSet.UpdateTileset()`, 125 | `ServiceSet.RemoveTileset()`, and `ServiceSet.HasTileset()` functions. 126 | WARNING: these functions are not yet thread-safe. 127 | 128 | ### Breaking changes 129 | 130 | #### Command-line interface: 131 | 132 | - ArcGIS endpoints are now opt-in via `--enable-arcgis` option (disabled by default) 133 | - `--path` option has been renamed to `--root-url` for clarity (env var is now `ROOT_URL`) 134 | - `--enable-reload` has been renamed to `--enable-reload-signal` 135 | 136 | #### Handlers API 137 | 138 | - `ServiceSet.Handler` parameters have been replaced with `ServiceSetConfig` 139 | passed to `handlers.New()` instead. 140 | - removed `handlers.NewFromBaseDir()`, replaced with `handlers.New()` and calling 141 | `ServiceSet.AddTileset()` for each `Tileset` to register. 142 | - removed `ServiceSet.AddDBOnPath()`; this is replaced by calling 143 | `ServiceSet.AddTileset()` for each `Tileset` to register. 144 | 145 | ### Bug fixes 146 | 147 | - Fixed WebP parsing, now uses simplified check for a `RIFF` header (WebP is only likely RIFF format to be stored in tiles). #98, #110 148 | 149 | ### Details 150 | 151 | This version involved a significant refactor of internal functionality and HTTP 152 | handlers to provide better ability to modify services at runtime, provide 153 | granular control over the endpoints that are exposed, and cleanup handling 154 | of middleware. 155 | 156 | Most internal HTTP handlers for `ServiceSet` and `Tileset` in the 157 | `github.com/consbio/mbtileserver/handlers` package are now `http.HandlerFunc`s 158 | instead of custom handlers that returned status codes or errors as in the previous 159 | versions. 160 | 161 | The internal routing within these handlers has been modified to enable 162 | tilesets to change at runtime. Previously, we were using an `http.ServeMux` 163 | for all routes, which breaks when the `Tileset` instances pointed to by those 164 | routes have changed at runtime. Now, the top-level `ServiceSet.Handler()` 165 | allows dynamic routing to any `Tileset` instances currently published. Each 166 | `Tileset` is now responsible for routing to its subpaths (e.g., tile endpoint). 167 | 168 | The singular public handler endpoint is still an `http.Handler` instance but 169 | no longer takes any parameters. Those parameters are now handled using 170 | configuration options instead. 171 | 172 | `ServiceSet` now enables configuration to set the root URL, toggle which endpoints 173 | are exposed and set the internal error logger. These are passed in using a 174 | `ServiceSetConfig` struct when the service is constructed; these configuration 175 | options are not modifiable at runtime. 176 | 177 | `Tileset` instances are now created individually from a set of source `mbtiles` 178 | files, instead of generated within `ServiceSet` from a directory. This provides 179 | more granular control over assigning IDs to tilesets as well as creating, 180 | updating, or deleting `Tileset` instances. You must generate unique IDs for 181 | tilesets before adding to the `ServiceSet`; you can use 182 | `handlers.SHA1ID(filename)` to generate a unique SHA1 ID of the service based on 183 | its full filename path, or `handlers.RelativePathID(filename, tilePath)` to 184 | generate the ID from its path and filename within the tile directory `tilePath`. 185 | 186 | HMAC authorization has been refactored into middleware external to the Go API. 187 | It now is instantiated as middleware in `main.go`; this provides better 188 | separation of concerns between the server (`main.go`) and the Go API. The API 189 | for interacting with HMAC authorization from the CLI or endpoints remains the 190 | same. 191 | 192 | Most of the updates are demonstrated in `main.go`. 193 | 194 | ## 0.6.1 195 | 196 | - upgraded Docker containers to Go 1.14 (solves out of memory issues during builds on small containers) 197 | 198 | ## 0.6 199 | 200 | - fixed bug in map preview when bounds are not defined for a tileset (#84) 201 | - updated Leaflet to 1.6.0 and Mapbox GL to 0.32.0 (larger upgrades contingent on #65) 202 | - fixed issues with `--tls` option (#89) 203 | - added example proxy configuration for Caddy and NGINX (#91) 204 | - fixed issues with map preview page using HTTP basemaps (#90) 205 | - resolved template loading issues (#85) 206 | 207 | ### Breaking changes - handlers API: 208 | 209 | - Removed `TemplatesFromAssets` as it was not used internally, and unlikely used externally 210 | - Removed `secretKey` from `NewFromBaseDir` parameters; this is replaced by calling `SetRequestAuthKey` on a `ServiceSet`. 211 | 212 | ## 0.5.0 213 | 214 | - Added Docker support (#74, #75) 215 | - Fix case-sensitive mbtiles URLs (#77) 216 | - Add support for graceful reloading (#69, #72, #73) 217 | - Add support for environment args (#70) 218 | - All changes prior to 6/1/2019 219 | -------------------------------------------------------------------------------- /handlers/tileset.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "net/http" 9 | "path/filepath" 10 | "strings" 11 | "syscall" 12 | "time" 13 | 14 | mbtiles "github.com/brendan-ward/mbtiles-go" 15 | ) 16 | 17 | // Tileset provides a tileset constructed from an mbtiles file 18 | type Tileset struct { 19 | svc *ServiceSet 20 | db *mbtiles.MBtiles 21 | id string 22 | name string 23 | tileformat mbtiles.TileFormat 24 | tilesize uint32 25 | published bool 26 | locked bool 27 | router *http.ServeMux 28 | } 29 | 30 | // newTileset constructs a new Tileset from an mbtiles filename. 31 | // Tileset is registered at the passed in path. 32 | // Any errors encountered opening the tileset are returned. 33 | func newTileset(svc *ServiceSet, filename, id, path string) (*Tileset, error) { 34 | db, err := mbtiles.Open(filename) 35 | if err != nil { 36 | return nil, fmt.Errorf("Invalid mbtiles file %q: %v", filename, err) 37 | } 38 | 39 | metadata, err := db.ReadMetadata() 40 | if err != nil { 41 | return nil, fmt.Errorf("Invalid mbtiles file %q: %v", filename, err) 42 | } 43 | 44 | name, ok := metadata["name"].(string) 45 | if !ok { 46 | name = strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) 47 | } 48 | 49 | ts := &Tileset{ 50 | svc: svc, 51 | db: db, 52 | id: id, 53 | name: name, 54 | tileformat: db.GetTileFormat(), 55 | tilesize: db.GetTileSize(), 56 | published: true, 57 | } 58 | 59 | // setup routes for tileset 60 | m := http.NewServeMux() 61 | m.HandleFunc(path+"/tiles/", ts.tileHandler) 62 | 63 | if svc.enableTileJSON { 64 | m.HandleFunc(path, ts.tileJSONHandler) 65 | } 66 | 67 | if svc.enablePreview { 68 | m.HandleFunc(path+"/map", ts.previewHandler) 69 | 70 | staticPrefix := path + "/map/static/" 71 | m.Handle(staticPrefix, staticHandler(staticPrefix)) 72 | } 73 | 74 | if svc.enableArcGIS { 75 | arcgisRoot := ArcGISServicesRoot + id + "/MapServer" 76 | m.HandleFunc(arcgisRoot, ts.arcgisServiceHandler) 77 | m.HandleFunc(arcgisRoot+"/layers", ts.arcgisLayersHandler) 78 | m.HandleFunc(arcgisRoot+"/legend", ts.arcgisLegendHandler) 79 | m.HandleFunc(arcgisRoot+"/tile/", ts.arcgisTileHandler) 80 | } 81 | 82 | ts.router = m 83 | 84 | return ts, nil 85 | } 86 | 87 | // Reload reloads the mbtiles file from disk using the same filename as 88 | // used when this was first constructed 89 | func (ts *Tileset) reload() error { 90 | if ts.db == nil { 91 | return nil 92 | } 93 | 94 | filename := ts.db.GetFilename() 95 | ts.db.Close() 96 | 97 | db, err := mbtiles.Open(filename) 98 | if err != nil { 99 | return fmt.Errorf("Invalid mbtiles file %q: %v", filename, err) 100 | } 101 | ts.db = db 102 | 103 | return nil 104 | } 105 | 106 | // Delete closes and deletes the mbtiles file connection for this tileset 107 | func (ts *Tileset) delete() error { 108 | if ts.db != nil { 109 | ts.db.Close() 110 | } 111 | ts.db = nil 112 | ts.published = false 113 | 114 | return nil 115 | } 116 | 117 | // tileFormatString returns the tile format string of the underlying mbtiles file 118 | func (ts *Tileset) tileFormatString() string { 119 | return ts.tileformat.String() 120 | } 121 | 122 | // TileJSON returns the TileJSON (as a map of strings to interface{} values) 123 | // for the tileset. This can be rendered into templates or returned via a 124 | // handler. 125 | func (ts *Tileset) TileJSON(svcURL string, query string) (map[string]interface{}, error) { 126 | if ts == nil || !ts.published { 127 | return nil, fmt.Errorf("Tileset does not exist") 128 | } 129 | 130 | db := ts.db 131 | 132 | imgFormat := db.GetTileFormat().String() 133 | out := map[string]interface{}{ 134 | "tilejson": "2.1.0", 135 | "scheme": "xyz", 136 | "format": imgFormat, 137 | "tiles": []string{fmt.Sprintf("%s/tiles/{z}/{x}/{y}.%s%s", svcURL, imgFormat, query)}, 138 | "name": ts.name, 139 | } 140 | 141 | if ts.tilesize > 0 { 142 | out["tilesize"] = ts.tilesize 143 | } 144 | 145 | metadata, err := db.ReadMetadata() 146 | if err != nil { 147 | return nil, err 148 | } 149 | for k, v := range metadata { 150 | switch k { 151 | // strip out values above 152 | case "tilejson", "id", "scheme", "format", "tiles", "map": 153 | continue 154 | 155 | // strip out values that are not supported or are overridden below 156 | case "grids", "interactivity", "modTime": 157 | continue 158 | 159 | // strip out values that come from TileMill but aren't useful here 160 | case "metatile", "scale", "autoscale", "_updated", "Layer", "Stylesheet": 161 | continue 162 | 163 | default: 164 | out[k] = v 165 | } 166 | } 167 | return out, nil 168 | } 169 | 170 | // tilesJSONHandler is an http.HandlerFunc for the TileJSON endpoint of the tileset 171 | func (ts *Tileset) tileJSONHandler(w http.ResponseWriter, r *http.Request) { 172 | if ts == nil || !ts.published { 173 | http.NotFound(w, r) 174 | return 175 | } 176 | 177 | // wait up to 30 seconds to see if tileset is ready and return it if possible 178 | if ts.isLockedWithTimeout(30 * time.Second) { 179 | tilesetLockedHandler(w, r) 180 | return 181 | } 182 | 183 | query := "" 184 | if r.URL.RawQuery != "" { 185 | query = "?" + r.URL.RawQuery 186 | } 187 | 188 | tilesetURL := fmt.Sprintf("%s://%s%s", scheme(r), getRequestHost(r), r.URL.Path) 189 | 190 | tileJSON, err := ts.TileJSON(tilesetURL, query) 191 | if err != nil { 192 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 193 | ts.svc.logError("could not create tileJSON content for %v: %v", r.URL.Path, err) 194 | return 195 | } 196 | 197 | if ts.svc.enablePreview { 198 | tileJSON["map"] = fmt.Sprintf("%s/map", tilesetURL) 199 | } 200 | 201 | bytes, err := json.Marshal(tileJSON) 202 | if err != nil { 203 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 204 | ts.svc.logError("could not render TileJSON for %v: %v", r.URL.Path, err) 205 | return 206 | } 207 | w.Header().Set("Content-Type", "application/json") 208 | _, err = w.Write(bytes) 209 | 210 | if err != nil { 211 | ts.svc.logError("could not write tileJSON content for %v: %v", r.URL.Path, err) 212 | } 213 | } 214 | 215 | // tileHandler is an http.HandlerFunc for the tile endpoint of the tileset. 216 | // If a tile is not found, the handler returns a blank image if the tileset 217 | // has images, and an empty response if the tileset has vector tiles. 218 | func (ts *Tileset) tileHandler(w http.ResponseWriter, r *http.Request) { 219 | if ts == nil || !ts.published { 220 | // In order to not break any requests from when this tileset was published 221 | // return the appropriate not found handler for the original tile format. 222 | tileNotFoundHandler(w, r, ts.tileformat, ts.tilesize, ts.svc.returnMissingImageTile404) 223 | return 224 | } 225 | 226 | // wait up to 30 seconds to see if tileset is ready and return it if possible 227 | if ts.isLockedWithTimeout(30 * time.Second) { 228 | tilesetLockedHandler(w, r) 229 | return 230 | } 231 | 232 | db := ts.db 233 | // split path components to extract tile coordinates x, y and z 234 | pcs := strings.Split(r.URL.Path[1:], "/") 235 | // we are expecting at least "services", , "tiles", , , 236 | l := len(pcs) 237 | if l < 6 || pcs[5] == "" { 238 | http.Error(w, "requested path is too short", http.StatusBadRequest) 239 | return 240 | } 241 | z, x, y := pcs[l-3], pcs[l-2], pcs[l-1] 242 | tc, _, err := tileCoordFromString(z, x, y) 243 | if err != nil { 244 | http.Error(w, "invalid tile coordinates", http.StatusBadRequest) 245 | return 246 | } 247 | var data []byte 248 | // flip y to match the spec 249 | tc.y = (1 << uint64(tc.z)) - 1 - tc.y 250 | err = db.ReadTile(tc.z, tc.x, tc.y, &data) 251 | 252 | if err != nil { 253 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 254 | ts.svc.logError("cannot fetch tile from DB for z=%d, x=%d, y=%d at path %v: %v", tc.z, tc.x, tc.y, r.URL.Path, err) 255 | return 256 | } 257 | if data == nil || len(data) <= 1 { 258 | tileNotFoundHandler(w, r, ts.tileformat, ts.tilesize, ts.svc.returnMissingImageTile404) 259 | return 260 | } 261 | 262 | w.Header().Set("Content-Type", db.GetTileFormat().MimeType()) 263 | if db.GetTileFormat() == mbtiles.PBF { 264 | w.Header().Set("Content-Encoding", "gzip") 265 | } 266 | 267 | w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) 268 | 269 | _, err = w.Write(data) 270 | 271 | if err != nil && !errors.Is(err, syscall.EPIPE) && !errors.Is(err, syscall.EPROTOTYPE) { 272 | ts.svc.logError("Could not write tile data for %v: %v", r.URL.Path, err) 273 | } 274 | } 275 | 276 | // previewHandler is an http.HandlerFunc that renders the map preview template 277 | // appropriate for the type of tileset. Image tilesets use Leaflet, whereas 278 | // vector tilesets use Mapbox GL. 279 | func (ts *Tileset) previewHandler(w http.ResponseWriter, r *http.Request) { 280 | if ts == nil || !ts.published { 281 | http.NotFound(w, r) 282 | return 283 | } 284 | 285 | // wait up to 30 seconds to see if tileset is ready and return it if possible 286 | if ts.isLockedWithTimeout(30 * time.Second) { 287 | tilesetLockedHandler(w, r) 288 | return 289 | } 290 | 291 | query := "" 292 | if r.URL.RawQuery != "" { 293 | query = "?" + r.URL.RawQuery 294 | } 295 | 296 | tilesetURL := fmt.Sprintf("%s://%s%s", scheme(r), getRequestHost(r), strings.TrimSuffix(r.URL.Path, "/map")) 297 | 298 | tileJSON, err := ts.TileJSON(tilesetURL, query) 299 | if err != nil { 300 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 301 | ts.svc.logError("could not create tileJSON content for %v: %v", r.URL.Path, err) 302 | return 303 | } 304 | 305 | bytes, err := json.Marshal(tileJSON) 306 | if err != nil { 307 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 308 | ts.svc.logError("could not render tileJSON for preview for %v: %v", r.URL.Path, err) 309 | return 310 | } 311 | 312 | p := struct { 313 | URL string 314 | ID string 315 | TileJSON template.JS 316 | BasemapStyleURL string 317 | BasemapTilesURL string 318 | }{ 319 | tilesetURL, 320 | ts.id, 321 | template.JS(string(bytes)), 322 | ts.svc.basemapStyleURL, 323 | ts.svc.basemapTilesURL, 324 | } 325 | 326 | executeTemplate(w, "map", p) 327 | } 328 | 329 | // tileNotFoundHandler is an http.HandlerFunc that writes the default response 330 | // for a non-existing tile of type f to w 331 | func tileNotFoundHandler(w http.ResponseWriter, r *http.Request, f mbtiles.TileFormat, tilesize uint32, returnMissingImageTile404 bool) { 332 | switch f { 333 | case mbtiles.PNG, mbtiles.JPG, mbtiles.WEBP: 334 | if returnMissingImageTile404 { 335 | // Return 404 336 | w.WriteHeader(http.StatusNotFound) 337 | } else { 338 | // Return blank PNG for all image types 339 | w.Header().Set("Content-Type", "image/png") 340 | w.WriteHeader(http.StatusOK) 341 | w.Write(BlankPNG(tilesize)) 342 | } 343 | case mbtiles.PBF: 344 | // Return 204 345 | w.WriteHeader(http.StatusNoContent) 346 | default: 347 | w.Header().Set("Content-Type", "application/json") 348 | w.WriteHeader(http.StatusNotFound) 349 | fmt.Fprint(w, `{"message": "Tile does not exist"}`) 350 | } 351 | } 352 | 353 | // tilesetLockedHandler returns a 503 Service Unavailable response when 354 | // requests are made to a tileset that is being updated 355 | func tilesetLockedHandler(w http.ResponseWriter, r *http.Request) { 356 | // send back service unavailable response with header to retry in 10 seconds 357 | w.Header().Set("Retry-After", "10") 358 | http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) 359 | } 360 | 361 | func (ts *Tileset) isLockedWithTimeout(timeout time.Duration) bool { 362 | if ts == nil || !ts.locked { 363 | return false 364 | } 365 | 366 | timeoutReached := time.After(timeout) 367 | // poll locked status every 500 ms 368 | ticker := time.Tick(500 * time.Millisecond) 369 | for { 370 | select { 371 | case <-timeoutReached: 372 | return ts.locked 373 | case <-ticker: 374 | if !ts.locked { 375 | return false 376 | } 377 | // otherwise, still locked 378 | } 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /handlers/arcgis.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "math" 6 | "net/http" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type arcGISLOD struct { 12 | Level int `json:"level"` 13 | Resolution float64 `json:"resolution"` 14 | Scale float64 `json:"scale"` 15 | } 16 | 17 | type arcGISSpatialReference struct { 18 | Wkid uint16 `json:"wkid"` 19 | } 20 | 21 | type arcGISExtent struct { 22 | Xmin float64 `json:"xmin"` 23 | Ymin float64 `json:"ymin"` 24 | Xmax float64 `json:"xmax"` 25 | Ymax float64 `json:"ymax"` 26 | SpatialReference arcGISSpatialReference `json:"spatialReference"` 27 | } 28 | 29 | type arcGISLayerStub struct { 30 | ID uint8 `json:"id"` 31 | Name string `json:"name"` 32 | ParentLayerID int16 `json:"parentLayerId"` 33 | DefaultVisibility bool `json:"defaultVisibility"` 34 | SubLayerIDs []uint8 `json:"subLayerIds"` 35 | MinScale float64 `json:"minScale"` 36 | MaxScale float64 `json:"maxScale"` 37 | } 38 | 39 | type arcGISLayer struct { 40 | ID uint8 `json:"id"` 41 | Name string `json:"name"` 42 | Type string `json:"type"` 43 | Description string `json:"description"` 44 | GeometryType string `json:"geometryType"` 45 | CopyrightText string `json:"copyrightText"` 46 | ParentLayer interface{} `json:"parentLayer"` 47 | SubLayers []arcGISLayerStub `json:"subLayers"` 48 | MinScale float64 `json:"minScale"` 49 | MaxScale float64 `json:"maxScale"` 50 | DefaultVisibility bool `json:"defaultVisibility"` 51 | Extent arcGISExtent `json:"extent"` 52 | HasAttachments bool `json:"hasAttachments"` 53 | HTMLPopupType string `json:"htmlPopupType"` 54 | DrawingInfo interface{} `json:"drawingInfo"` 55 | DisplayField interface{} `json:"displayField"` 56 | Fields []interface{} `json:"fields"` 57 | TypeIDField interface{} `json:"typeIdField"` 58 | Types interface{} `json:"types"` 59 | Relationships []interface{} `json:"relationships"` 60 | Capabilities string `json:"capabilities"` 61 | CurrentVersion float32 `json:"currentVersion"` 62 | } 63 | 64 | // root of ArcGIS server is at /arcgis/rest/ 65 | // all map services are served under /arcgis/rest/services 66 | 67 | const ArcGISRoot = "/arcgis/rest/" 68 | const ArcGISServicesRoot = ArcGISRoot + "services/" 69 | const ArcGISInfoRoot = ArcGISRoot + "info" 70 | 71 | var webMercatorSR = arcGISSpatialReference{Wkid: 3857} 72 | var geographicSR = arcGISSpatialReference{Wkid: 4326} 73 | 74 | func arcgisInfoJSON() ([]byte, error) { 75 | out := map[string]interface{}{ 76 | "currentVersion": 10.71, 77 | "fullVersion": "10.7.1", 78 | "soapUrl": nil, 79 | "secureSoapUrl": nil, 80 | } 81 | 82 | bytes, err := json.Marshal(out) 83 | if err != nil { 84 | return nil, err 85 | } 86 | return bytes, nil 87 | } 88 | 89 | func (svc *ServiceSet) arcgisInfoHandler(w http.ResponseWriter, r *http.Request) { 90 | infoJSON, err := arcgisInfoJSON() 91 | 92 | if err != nil { 93 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 94 | svc.logError("Could not render ArcGIS Server Info JSON for %v: %v", r.URL.Path, err) 95 | return 96 | } 97 | 98 | err = wrapJSONP(w, r, infoJSON) 99 | 100 | if err != nil { 101 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 102 | svc.logError("Could not render ArcGIS Server Info JSON to JSONP for %v: %v", r.URL.Path, err) 103 | } 104 | } 105 | 106 | // arcGISServiceJSON returns ArcGIS standard JSON describing the ArcGIS 107 | // tile service. 108 | func (ts *Tileset) arcgisServiceJSON() ([]byte, error) { 109 | db := ts.db 110 | imgFormat := db.GetTileFormat().String() 111 | metadata, err := db.ReadMetadata() 112 | if err != nil { 113 | return nil, err 114 | } 115 | name, _ := metadata["name"].(string) 116 | description, _ := metadata["description"].(string) 117 | attribution, _ := metadata["attribution"].(string) 118 | tags, _ := metadata["tags"].(string) 119 | credits, _ := metadata["credits"].(string) 120 | 121 | // TODO: make sure that min and max zoom always populated 122 | minZoom, _ := metadata["minzoom"].(int) 123 | maxZoom, _ := metadata["maxzoom"].(int) 124 | // TODO: extract dpi from the image instead 125 | var lods []arcGISLOD 126 | 127 | for i := minZoom; i <= maxZoom; i++ { 128 | scale, resolution := calcScaleResolution(i, dpi) 129 | lods = append(lods, arcGISLOD{ 130 | Level: i, 131 | Resolution: resolution, 132 | Scale: scale, 133 | }) 134 | } 135 | 136 | minScale := lods[0].Scale 137 | maxScale := lods[len(lods)-1].Scale 138 | 139 | bounds, ok := metadata["bounds"].([]float64) 140 | if !ok { 141 | bounds = []float64{-180, -85, 180, 85} // default to world bounds 142 | } 143 | extent := geoBoundsToWMExtent(bounds) 144 | 145 | tileInfo := map[string]interface{}{ 146 | "rows": 256, 147 | "cols": 256, 148 | "dpi": dpi, 149 | "origin": map[string]float64{ 150 | "x": -earthCircumference, 151 | "y": earthCircumference, 152 | }, 153 | "spatialReference": webMercatorSR, 154 | "lods": lods, 155 | } 156 | 157 | documentInfo := map[string]string{ 158 | "Title": name, 159 | "Author": attribution, 160 | "Comments": "", 161 | "Subject": "", 162 | "Category": "", 163 | "Keywords": tags, 164 | "Credits": credits, 165 | } 166 | 167 | out := map[string]interface{}{ 168 | "currentVersion": "10.4", 169 | "id": ts.id, 170 | "name": name, 171 | "mapName": name, 172 | "capabilities": "Map,TilesOnly", 173 | "description": description, 174 | "serviceDescription": description, 175 | "copyrightText": attribution, 176 | "singleFusedMapCache": true, 177 | "supportedImageFormatTypes": strings.ToUpper(imgFormat), 178 | "units": "esriMeters", 179 | // TODO: enable for vector tiles 180 | // "layers": []arcGISLayerStub{ 181 | // { 182 | // ID: 0, 183 | // Name: name, 184 | // ParentLayerID: -1, 185 | // DefaultVisibility: true, 186 | // SubLayerIDs: nil, 187 | // MinScale: minScale, 188 | // MaxScale: maxScale, 189 | // }, 190 | // }, 191 | "layers": []string{}, 192 | "tables": []string{}, 193 | "spatialReference": webMercatorSR, 194 | "minScale": minScale, 195 | "maxScale": maxScale, 196 | "tileInfo": tileInfo, 197 | "documentInfo": documentInfo, 198 | "initialExtent": extent, 199 | "fullExtent": extent, 200 | "exportTilesAllowed": false, 201 | "maxExportTilesCount": 0, 202 | "resampling": false, 203 | "supportsDynamicLayers": false, 204 | } 205 | 206 | bytes, err := json.Marshal(out) 207 | if err != nil { 208 | return nil, err 209 | } 210 | return bytes, nil 211 | } 212 | 213 | // arcgisServiceHandler is an http.HandlerFunc that returns standard ArcGIS 214 | // JSON for a given ArcGIS tile service 215 | func (ts *Tileset) arcgisServiceHandler(w http.ResponseWriter, r *http.Request) { 216 | // wait up to 30 seconds to see if tileset is ready and return it if possible 217 | if ts.isLockedWithTimeout(30 * time.Second) { 218 | tilesetLockedHandler(w, r) 219 | return 220 | } 221 | 222 | svcJSON, err := ts.arcgisServiceJSON() 223 | 224 | if err != nil { 225 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 226 | ts.svc.logError("Could not render ArcGIS Service JSON for %v: %v", r.URL.Path, err) 227 | return 228 | } 229 | 230 | err = wrapJSONP(w, r, svcJSON) 231 | 232 | if err != nil { 233 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 234 | ts.svc.logError("Could not render ArcGIS Service JSON to JSONP for %v: %v", r.URL.Path, err) 235 | } 236 | } 237 | 238 | // arcGISLayersJSON returns JSON for the layers in a given ArcGIS tile service 239 | func (ts *Tileset) arcgisLayersJSON() ([]byte, error) { 240 | // TODO: enable for vector tiles 241 | 242 | // metadata, err := ts.db.ReadMetadata() 243 | // if err != nil { 244 | // return nil, err 245 | // } 246 | 247 | // name, _ := metadata["name"].(string) 248 | // description, _ := metadata["description"].(string) 249 | // attribution, _ := metadata["attribution"].(string) 250 | 251 | // bounds, ok := metadata["bounds"].([]float32) 252 | // if !ok { 253 | // bounds = []float32{-180, -85, 180, 85} // default to world bounds 254 | // } 255 | // extent := geoBoundsToWMExtent(bounds) 256 | 257 | // minZoom, _ := metadata["minzoom"].(int) 258 | // maxZoom, _ := metadata["maxzoom"].(int) 259 | // minScale, _ := calcScaleResolution(minZoom, dpi) 260 | // maxScale, _ := calcScaleResolution(maxZoom, dpi) 261 | 262 | // // for now, just create a placeholder root layer 263 | // emptyArray := []interface{}{} 264 | // emptyLayerArray := []arcGISLayerStub{} 265 | 266 | // var layers [1]arcGISLayer 267 | // layers[0] = arcGISLayer{ 268 | // ID: 0, 269 | // DefaultVisibility: true, 270 | // ParentLayer: nil, 271 | // Name: name, 272 | // Description: description, 273 | // Extent: extent, 274 | // MinScale: minScale, 275 | // MaxScale: maxScale, 276 | // CopyrightText: attribution, 277 | // HTMLPopupType: "esriServerHTMLPopupTypeAsHTMLText", 278 | // Fields: emptyArray, 279 | // Relationships: emptyArray, 280 | // SubLayers: emptyLayerArray, 281 | // CurrentVersion: 10.4, 282 | // Capabilities: "Map", 283 | // } 284 | 285 | out := map[string]interface{}{ 286 | // "layers": layers, 287 | "layers": []string{}, 288 | "tables": []string{}, 289 | } 290 | 291 | bytes, err := json.Marshal(out) 292 | if err != nil { 293 | return nil, err 294 | } 295 | return bytes, nil 296 | } 297 | 298 | // arcgisLayersHandler is an http.HandlerFunc that returns standard ArcGIS 299 | // Layers JSON for a given ArcGIS tile service 300 | func (ts *Tileset) arcgisLayersHandler(w http.ResponseWriter, r *http.Request) { 301 | // wait up to 30 seconds to see if tileset is ready and return it if possible 302 | if ts.isLockedWithTimeout(30 * time.Second) { 303 | tilesetLockedHandler(w, r) 304 | return 305 | } 306 | 307 | layersJSON, err := ts.arcgisLayersJSON() 308 | if err != nil { 309 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 310 | ts.svc.logError("Could not render ArcGIS layer JSON for %v: %v", r.URL.Path, err) 311 | return 312 | } 313 | 314 | err = wrapJSONP(w, r, layersJSON) 315 | 316 | if err != nil { 317 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 318 | ts.svc.logError("Could not render ArcGIS layers JSON to JSONP for %v: %v", r.URL.Path, err) 319 | } 320 | } 321 | 322 | // arcgisLegendJSON returns minimal ArcGIS legend JSON for a given ArcGIS 323 | // tile service. Legend elements are not yet supported. 324 | func (ts *Tileset) arcgisLegendJSON() ([]byte, error) { 325 | metadata, err := ts.db.ReadMetadata() 326 | if err != nil { 327 | return nil, err 328 | } 329 | 330 | name, _ := metadata["name"].(string) 331 | 332 | // TODO: pull the legend from ArcGIS specific metadata tables 333 | var elements [0]interface{} 334 | var layers [1]map[string]interface{} 335 | 336 | layers[0] = map[string]interface{}{ 337 | "layerId": 0, 338 | "layerName": name, 339 | "layerType": "", 340 | "minScale": 0, 341 | "maxScale": 0, 342 | "legend": elements, 343 | } 344 | 345 | out := map[string]interface{}{ 346 | "layers": layers, 347 | } 348 | 349 | bytes, err := json.Marshal(out) 350 | if err != nil { 351 | return nil, err 352 | } 353 | return bytes, nil 354 | } 355 | 356 | // arcgisLegendHandler is an http.HandlerFunc that returns minimal ArcGIS 357 | // legend JSON for a given ArcGIS tile service 358 | func (ts *Tileset) arcgisLegendHandler(w http.ResponseWriter, r *http.Request) { 359 | // wait up to 30 seconds to see if tileset is ready and return it if possible 360 | if ts.isLockedWithTimeout(30 * time.Second) { 361 | tilesetLockedHandler(w, r) 362 | return 363 | } 364 | 365 | legendJSON, err := ts.arcgisLegendJSON() 366 | 367 | if err != nil { 368 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 369 | ts.svc.logError("Could not render ArcGIS legend JSON for %v: %v", r.URL.Path, err) 370 | return 371 | } 372 | 373 | err = wrapJSONP(w, r, legendJSON) 374 | 375 | if err != nil { 376 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 377 | ts.svc.logError("Could not render ArcGIS legend JSON to JSONP for %v: %v", r.URL.Path, err) 378 | } 379 | } 380 | 381 | // arcgisTileHandler returns an image tile or blank image for a given 382 | // tile request within a given ArcGIS tile service 383 | func (ts *Tileset) arcgisTileHandler(w http.ResponseWriter, r *http.Request) { 384 | 385 | // wait up to 30 seconds to see if tileset is ready and return it if possible 386 | if ts.isLockedWithTimeout(30 * time.Second) { 387 | tilesetLockedHandler(w, r) 388 | return 389 | } 390 | 391 | db := ts.db 392 | 393 | // split path components to extract tile coordinates x, y and z 394 | pcs := strings.Split(r.URL.Path[1:], "/") 395 | // strip off /arcgis/rest/services/ and then 396 | // we should have at least , "MapServer", "tiles", , , 397 | l := len(pcs) 398 | if l < 6 || pcs[5] == "" { 399 | http.Error(w, "requested path is too short", http.StatusBadRequest) 400 | return 401 | } 402 | z, y, x := pcs[l-3], pcs[l-2], pcs[l-1] 403 | tc, _, err := tileCoordFromString(z, x, y) 404 | if err != nil { 405 | http.Error(w, "invalid tile coordinates", http.StatusBadRequest) 406 | return 407 | } 408 | 409 | // flip y to match the spec 410 | tc.y = (1 << uint64(tc.z)) - 1 - tc.y 411 | 412 | var data []byte 413 | err = db.ReadTile(tc.z, tc.x, tc.y, &data) 414 | 415 | if err != nil { 416 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 417 | ts.svc.logError("cannot fetch tile from DB for z=%d, x=%d, y=%d for %v: %v", tc.z, tc.x, tc.y, r.URL.Path, err) 418 | return 419 | } 420 | 421 | if data == nil || len(data) <= 1 { 422 | // Return blank PNG for all image types 423 | w.Header().Set("Content-Type", "image/png") 424 | _, err = w.Write(BlankPNG(ts.tilesize)) 425 | 426 | if err != nil { 427 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 428 | ts.svc.logError("could not return blank image for %v: %v", r.URL.Path, err) 429 | } 430 | } else { 431 | w.Header().Set("Content-Type", db.GetTileFormat().MimeType()) 432 | _, err = w.Write(data) 433 | 434 | if err != nil { 435 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 436 | ts.svc.logError("could not write tile data to response for %v: %v", r.URL.Path, err) 437 | } 438 | } 439 | } 440 | 441 | func geoBoundsToWMExtent(bounds []float64) arcGISExtent { 442 | xmin, ymin := geoToMercator(float64(bounds[0]), float64(bounds[1])) 443 | xmax, ymax := geoToMercator(float64(bounds[2]), float64(bounds[3])) 444 | return arcGISExtent{ 445 | Xmin: xmin, 446 | Ymin: ymin, 447 | Xmax: xmax, 448 | Ymax: ymax, 449 | SpatialReference: webMercatorSR, 450 | } 451 | } 452 | 453 | // Cast interface to a string if not nil, otherwise empty string 454 | func toString(s interface{}) string { 455 | if s != nil { 456 | return s.(string) 457 | } 458 | return "" 459 | } 460 | 461 | // Convert a latitude and longitude to mercator coordinates, bounded to world domain. 462 | func geoToMercator(longitude, latitude float64) (float64, float64) { 463 | // bound to world coordinates 464 | if latitude > 85 { 465 | latitude = 85 466 | } else if latitude < -85 { 467 | latitude = -85 468 | } 469 | 470 | x := longitude * earthCircumference / 180 471 | y := math.Log(math.Tan((90+latitude)*math.Pi/360)) / (math.Pi / 180) * (earthCircumference / 180) 472 | 473 | return x, y 474 | } 475 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "os/exec" 8 | "os/signal" 9 | "strconv" 10 | "strings" 11 | "syscall" 12 | 13 | "golang.org/x/crypto/acme" 14 | "golang.org/x/crypto/acme/autocert" 15 | 16 | "net" 17 | "net/http" 18 | "net/url" 19 | "os" 20 | "time" 21 | 22 | "github.com/labstack/echo/v4" 23 | 24 | "github.com/evalphobia/logrus_sentry" 25 | "github.com/labstack/echo/v4/middleware" 26 | log "github.com/sirupsen/logrus" 27 | "github.com/spf13/cobra" 28 | 29 | mbtiles "github.com/brendan-ward/mbtiles-go" 30 | "github.com/consbio/mbtileserver/handlers" 31 | ) 32 | 33 | var rootCmd = &cobra.Command{ 34 | Use: "mbtileserver", 35 | Short: "Serve tiles from mbtiles files", 36 | Run: func(cmd *cobra.Command, args []string) { 37 | // default to listening on all interfaces 38 | if host == "" { 39 | host = "0.0.0.0" 40 | } 41 | // if port is not provided by user (after parsing command line args), use defaults 42 | if port == -1 { 43 | if len(certificate) > 0 || autotls { 44 | port = 443 45 | } else { 46 | port = 8000 47 | } 48 | } 49 | 50 | if enableReloadSignal { 51 | if isChild := os.Getenv("MBTS_IS_CHILD"); isChild != "" { 52 | serve() 53 | } else { 54 | supervise() 55 | } 56 | } else { 57 | serve() 58 | } 59 | }, 60 | } 61 | 62 | var ( 63 | host string 64 | port int 65 | tilePath string 66 | certificate string 67 | privateKey string 68 | rootURLStr string 69 | domain string 70 | secretKey string 71 | sentryDSN string 72 | verbose bool 73 | autotls bool 74 | redirect bool 75 | enableReloadSignal bool 76 | enableReloadFSWatch bool 77 | generateIDs bool 78 | enableArcGIS bool 79 | disablePreview bool 80 | disableTileJSON bool 81 | disableServiceList bool 82 | tilesOnly bool 83 | basemapStyleURL string 84 | basemapTilesURL string 85 | missingImageTile404 bool 86 | ) 87 | 88 | func init() { 89 | flags := rootCmd.Flags() 90 | flags.StringVar(&host, "host", "0.0.0.0", "IP address to listen on. Default is all interfaces.") 91 | flags.IntVarP(&port, "port", "p", -1, "Server port. Default is 443 if --cert or --tls options are used, otherwise 8000.") 92 | flags.StringVarP(&tilePath, "dir", "d", "./tilesets", "Directory containing mbtiles files. Can be a comma-delimited list of directories.") 93 | flags.BoolVarP(&generateIDs, "generate-ids", "", false, "Automatically generate tileset IDs instead of using relative path") 94 | flags.StringVarP(&certificate, "cert", "c", "", "X.509 TLS certificate filename. If present, will be used to enable SSL on the server.") 95 | flags.StringVarP(&privateKey, "key", "k", "", "TLS private key") 96 | flags.StringVar(&rootURLStr, "root-url", "/services", "Root URL of services endpoint") 97 | flags.StringVar(&domain, "domain", "", "Domain name of this server. NOTE: only used for Auto TLS.") 98 | flags.StringVarP(&secretKey, "secret-key", "s", "", "Shared secret key used for HMAC request authentication") 99 | flags.BoolVarP(&autotls, "tls", "t", false, "Auto TLS via Let's Encrypt. Requires domain to be set") 100 | flags.BoolVarP(&redirect, "redirect", "r", false, "Redirect HTTP to HTTPS") 101 | 102 | flags.BoolVarP(&enableArcGIS, "enable-arcgis", "", false, "Enable ArcGIS Mapserver endpoints") 103 | flags.BoolVarP(&enableReloadFSWatch, "enable-fs-watch", "", false, "Enable reloading of tilesets by watching filesystem") 104 | flags.BoolVarP(&enableReloadSignal, "enable-reload-signal", "", false, "Enable graceful reload using HUP signal to the server process") 105 | 106 | flags.BoolVarP(&disablePreview, "disable-preview", "", false, "Disable map preview for each tileset (enabled by default)") 107 | flags.BoolVarP(&disableTileJSON, "disable-tilejson", "", false, "Disable TileJSON endpoint for each tileset (enabled by default)") 108 | flags.BoolVarP(&disableServiceList, "disable-svc-list", "", false, "Disable services list endpoint (enabled by default)") 109 | flags.BoolVarP(&tilesOnly, "tiles-only", "", false, "Only enable tile endpoints (shortcut for --disable-svc-list --disable-tilejson --disable-preview)") 110 | 111 | flags.StringVar(&sentryDSN, "dsn", "", "Sentry DSN") 112 | 113 | flags.StringVar(&basemapStyleURL, "basemap-style-url", "", "Basemap style URL for preview endpoint (can include authorization token parameter if required by host)") 114 | flags.StringVar(&basemapTilesURL, "basemap-tiles-url", "", "Basemap raster tiles URL pattern for preview endpoint (can include authorization token parameter if required by host): https://some.host/{z}/{x}/{y}.png") 115 | 116 | flags.BoolVarP(&missingImageTile404, "missing-image-tile-404", "", false, "Return HTTP 404 error code when image tile is misssing instead of default behavior to return blank PNG") 117 | 118 | flags.BoolVarP(&verbose, "verbose", "v", false, "Verbose logging") 119 | 120 | if env := os.Getenv("HOST"); env != "" { 121 | host = env 122 | } 123 | 124 | if env := os.Getenv("PORT"); env != "" { 125 | p, err := strconv.Atoi(env) 126 | if err != nil { 127 | log.Fatalln("PORT must be a number") 128 | } 129 | port = p 130 | } 131 | 132 | if env := os.Getenv("TILE_DIR"); env != "" { 133 | tilePath = env 134 | } 135 | 136 | if env := os.Getenv("GENERATE_IDS"); env != "" { 137 | p, err := strconv.ParseBool(env) 138 | if err != nil { 139 | log.Fatalln("GENERATE_IDS must be a bool(true/false)") 140 | } 141 | generateIDs = p 142 | } 143 | 144 | if env := os.Getenv("TLS_CERT"); env != "" { 145 | certificate = env 146 | } 147 | 148 | if env := os.Getenv("TLS_PRIVATE_KEY"); env != "" { 149 | privateKey = env 150 | } 151 | 152 | if env := os.Getenv("ROOT_URL"); env != "" { 153 | rootURLStr = env 154 | } 155 | 156 | if env := os.Getenv("DOMAIN"); env != "" { 157 | domain = env 158 | } 159 | if secretKey == "" { 160 | secretKey = os.Getenv("HMAC_SECRET_KEY") 161 | } 162 | 163 | if env := os.Getenv("AUTO_TLS"); env != "" { 164 | p, err := strconv.ParseBool(env) 165 | if err != nil { 166 | log.Fatalln("AUTO_TLS must be a bool(true/false)") 167 | } 168 | autotls = p 169 | } 170 | 171 | if env := os.Getenv("REDIRECT"); env != "" { 172 | p, err := strconv.ParseBool(env) 173 | if err != nil { 174 | log.Fatalln("REDIRECT must be a bool(true/false)") 175 | } 176 | redirect = p 177 | } 178 | 179 | if env := os.Getenv("DSN"); env != "" { 180 | sentryDSN = env 181 | } 182 | 183 | if env := os.Getenv("ENABLE_ARCGIS"); env != "" { 184 | p, err := strconv.ParseBool(env) 185 | if err != nil { 186 | log.Fatalln("ENABLE_ARCGIS must be a bool(true/false)") 187 | } 188 | enableArcGIS = p 189 | } 190 | 191 | if env := os.Getenv("ENABLE_FS_WATCH"); env != "" { 192 | p, err := strconv.ParseBool(env) 193 | if err != nil { 194 | log.Fatalln("ENABLE_FS_WATCH must be a bool(true/false)") 195 | } 196 | enableReloadFSWatch = p 197 | } 198 | 199 | if env := os.Getenv("ENABLE_RELOAD_SIGNAL"); env != "" { 200 | p, err := strconv.ParseBool(env) 201 | if err != nil { 202 | log.Fatalln("ENABLE_RELOAD_SIGNAL must be a bool(true/false)") 203 | } 204 | enableReloadSignal = p 205 | } 206 | 207 | if env := os.Getenv("VERBOSE"); env != "" { 208 | p, err := strconv.ParseBool(env) 209 | if err != nil { 210 | log.Fatalln("VERBOSE must be a bool(true/false)") 211 | } 212 | verbose = p 213 | } 214 | } 215 | 216 | func main() { 217 | if err := rootCmd.Execute(); err != nil { 218 | log.Fatalln(err) 219 | } 220 | } 221 | 222 | func serve() { 223 | if verbose { 224 | log.SetLevel(log.DebugLevel) 225 | } 226 | 227 | if len(sentryDSN) > 0 { 228 | hook, err := logrus_sentry.NewSentryHook(sentryDSN, []log.Level{ 229 | log.PanicLevel, 230 | log.FatalLevel, 231 | log.ErrorLevel, 232 | log.WarnLevel, 233 | }) 234 | if err != nil { 235 | log.Fatalln(err) 236 | } 237 | hook.Timeout = 30 * time.Second // allow up to 30 seconds for Sentry to respond 238 | log.AddHook(hook) 239 | log.Debugln("Added logging hook for Sentry") 240 | } 241 | 242 | if tilesOnly { 243 | disableServiceList = true 244 | disableTileJSON = true 245 | disablePreview = true 246 | } 247 | 248 | if disablePreview { 249 | basemapStyleURL = "" 250 | basemapTilesURL = "" 251 | } 252 | 253 | if !strings.HasPrefix(rootURLStr, "/") { 254 | log.Fatalln("Value for --root-url must start with \"/\"") 255 | } 256 | if strings.HasSuffix(rootURLStr, "/") { 257 | log.Fatalln("Value for --root-url must not end with \"/\"") 258 | } 259 | 260 | rootURL, err := url.Parse(rootURLStr) 261 | if err != nil { 262 | log.Fatalf("Could not parse --root-url value %q\n", rootURLStr) 263 | } 264 | 265 | certExists := len(certificate) > 0 266 | keyExists := len(privateKey) > 0 267 | domainExists := len(domain) > 0 268 | 269 | if certExists != keyExists { 270 | log.Fatalln("Both certificate and private key are required to use SSL") 271 | } 272 | 273 | if autotls && !domainExists { 274 | log.Fatalln("Domain is required to use auto TLS") 275 | } 276 | 277 | if (certExists || autotls) && port != 443 { 278 | log.Warnln("Port 443 should be used for TLS") 279 | } 280 | 281 | if redirect && !(certExists || autotls) { 282 | log.Fatalln("Certificate or tls options are required to use redirect") 283 | } 284 | 285 | if len(secretKey) > 0 { 286 | log.Infoln("An HMAC request authorization key was set. All incoming must be signed.") 287 | } 288 | 289 | generateID := func(filename string, baseDir string) (string, error) { 290 | if generateIDs { 291 | return handlers.SHA1ID(filename), nil 292 | } else { 293 | return handlers.RelativePathID(filename, baseDir) 294 | } 295 | } 296 | 297 | svcSet, err := handlers.New(&handlers.ServiceSetConfig{ 298 | RootURL: rootURL, 299 | ErrorWriter: &errorLogger{log: log.New()}, 300 | EnableServiceList: !disableServiceList, 301 | EnableTileJSON: !disableTileJSON, 302 | EnablePreview: !disablePreview, 303 | EnableArcGIS: enableArcGIS, 304 | BasemapStyleURL: basemapStyleURL, 305 | BasemapTilesURL: basemapTilesURL, 306 | ReturnMissingImageTile404: missingImageTile404, 307 | }) 308 | if err != nil { 309 | log.Fatalln("Could not construct ServiceSet") 310 | } 311 | 312 | for _, path := range strings.Split(tilePath, ",") { 313 | // Discover all tilesets 314 | log.Infof("Searching for tilesets in %v\n", path) 315 | filenames, err := mbtiles.FindMBtiles(path) 316 | if err != nil { 317 | log.Errorf("Unable to list mbtiles in '%v': %v\n", path, err) 318 | } 319 | if len(filenames) == 0 { 320 | log.Errorf("No tilesets found in %s", path) 321 | } 322 | 323 | // Register all tilesets 324 | for _, filename := range filenames { 325 | id, err := generateID(filename, path) 326 | if err != nil { 327 | log.Errorf("Could not generate ID for tileset: %q", filename) 328 | continue 329 | } 330 | 331 | err = svcSet.AddTileset(filename, id) 332 | if err != nil { 333 | log.Errorf("Could not add tileset for %q with ID %q\n%v", filename, id, err) 334 | } 335 | } 336 | } 337 | 338 | // print number of services 339 | log.Infof("Published %v services", svcSet.Size()) 340 | 341 | // watch filesystem for changes to tilesets 342 | if enableReloadFSWatch { 343 | watcher, err := NewFSWatcher(svcSet, generateID) 344 | if err != nil { 345 | log.Fatalln("Could not construct filesystem watcher") 346 | } 347 | defer watcher.Close() 348 | 349 | for _, path := range strings.Split(tilePath, ",") { 350 | log.Infof("Watching %v\n", path) 351 | err = watcher.WatchDir((path)) 352 | if err != nil { 353 | // If we cannot enable file watching, then this should be a fatal 354 | // error during server startup 355 | log.Fatalln("Could not enable filesystem watcher in", path, err) 356 | } 357 | } 358 | } 359 | 360 | e := echo.New() 361 | e.HideBanner = true 362 | e.Pre(middleware.RemoveTrailingSlash()) 363 | e.Use(middleware.Recover()) 364 | e.Use(middleware.CORS()) 365 | 366 | // log all requests if verbose mode 367 | if verbose { 368 | e.Use(middleware.Logger()) 369 | } 370 | 371 | // setup auth middleware if secret key is set 372 | if secretKey != "" { 373 | hmacAuth := handlers.HMACAuthMiddleware(secretKey, svcSet) 374 | e.Use(echo.WrapMiddleware(hmacAuth)) 375 | } 376 | 377 | // Get HTTP.Handler for the service set, and wrap for use in echo 378 | e.GET("/*", echo.WrapHandler(svcSet.Handler())) 379 | 380 | // Start the server 381 | fmt.Println("\n--------------------------------------") 382 | fmt.Println("Use Ctrl-C to exit the server") 383 | fmt.Println("--------------------------------------") 384 | 385 | // If starting TLS on 443, start server on port 80 too 386 | if redirect { 387 | e.Pre(middleware.HTTPSRedirect()) 388 | if port == 443 { 389 | go func(c *echo.Echo) { 390 | fmt.Printf("HTTP server with redirect started on %v:80\n", host) 391 | log.Fatal(e.Start(fmt.Sprintf("%v:%v", host, 80))) 392 | }(e) 393 | } 394 | } 395 | 396 | var listener net.Listener 397 | 398 | if enableReloadSignal { 399 | f := os.NewFile(3, "") 400 | listener, err = net.FileListener(f) 401 | } else { 402 | listener, err = net.Listen("tcp", fmt.Sprintf("%v:%v", host, port)) 403 | } 404 | 405 | if err != nil { 406 | log.Fatal(err) 407 | } 408 | 409 | server := &http.Server{Handler: e} 410 | 411 | // Listen for SIGHUP (graceful shutdown) 412 | go func(e *echo.Echo) { 413 | if !enableReloadSignal { 414 | return 415 | } 416 | 417 | hup := make(chan os.Signal, 1) 418 | signal.Notify(hup, syscall.SIGHUP) 419 | 420 | <-hup 421 | 422 | context, cancel := context.WithTimeout(context.Background(), 60*time.Second) 423 | defer cancel() 424 | e.Shutdown(context) 425 | 426 | os.Exit(0) 427 | }(e) 428 | 429 | switch { 430 | case certExists: 431 | { 432 | log.Debug("Starting HTTPS using provided certificate") 433 | if _, err := os.Stat(certificate); os.IsNotExist(err) { 434 | log.Fatalf("Could not find certificate file: %s\n", certificate) 435 | } 436 | if _, err := os.Stat(privateKey); os.IsNotExist(err) { 437 | log.Fatalf("Could not find private key file: %s\n", privateKey) 438 | } 439 | 440 | fmt.Printf("HTTPS server started on %v:%v\n", host, port) 441 | log.Fatal(server.ServeTLS(listener, certificate, privateKey)) 442 | } 443 | case autotls: 444 | { 445 | log.Debug("Starting HTTPS using Let's Encrypt") 446 | 447 | // Setup certificate cache directory and TLS config 448 | e.AutoTLSManager.Cache = autocert.DirCache(".certs") 449 | e.AutoTLSManager.HostPolicy = autocert.HostWhitelist(domain) 450 | 451 | server.TLSConfig = new(tls.Config) 452 | server.TLSConfig.GetCertificate = e.AutoTLSManager.GetCertificate 453 | server.TLSConfig.NextProtos = append(server.TLSConfig.NextProtos, acme.ALPNProto) 454 | if !e.DisableHTTP2 { 455 | server.TLSConfig.NextProtos = append(server.TLSConfig.NextProtos, "h2") 456 | } 457 | 458 | tlsListener := tls.NewListener(listener, server.TLSConfig) 459 | 460 | fmt.Printf("HTTPS server started on %v:%v\n", host, port) 461 | log.Fatal(server.Serve(tlsListener)) 462 | } 463 | default: 464 | { 465 | fmt.Printf("HTTP server started on %v:%v\n", host, port) 466 | log.Fatal(server.Serve(listener)) 467 | } 468 | } 469 | } 470 | 471 | // The main process forks and manages a sub-process for graceful reloading 472 | func supervise() { 473 | 474 | listener, err := net.Listen("tcp", fmt.Sprintf("%v:%v", host, port)) 475 | if err != nil { 476 | log.Fatal(err) 477 | } 478 | 479 | createFork := func() *exec.Cmd { 480 | environment := append(os.Environ(), "MBTS_IS_CHILD=true") 481 | path, err := os.Executable() 482 | if err != nil { 483 | log.Fatal(err) 484 | } 485 | 486 | listenerFile, err := listener.(*net.TCPListener).File() 487 | if err != nil { 488 | log.Fatal(err) 489 | } 490 | 491 | cmd := exec.Command(path, os.Args...) 492 | cmd.Env = environment 493 | cmd.Stdout = os.Stdout 494 | cmd.Stderr = os.Stderr 495 | cmd.ExtraFiles = []*os.File{listenerFile} 496 | 497 | cmd.Start() 498 | 499 | return cmd 500 | } 501 | 502 | killFork := func(cmd *exec.Cmd) { 503 | done := make(chan error, 1) 504 | go func() { 505 | done <- cmd.Wait() 506 | }() 507 | cmd.Process.Signal(syscall.SIGHUP) // Signal fork to shut down gracefully 508 | 509 | select { 510 | case <-time.After(30 * time.Second): // Give fork 30 seconds to shut down gracefully 511 | if err := cmd.Process.Kill(); err != nil { 512 | log.Errorf("Could not kill child process: %v", err) 513 | } 514 | case <-done: 515 | return 516 | } 517 | } 518 | 519 | var child *exec.Cmd 520 | shutdown := false 521 | 522 | // Graceful shutdown on Ctrl + C 523 | go func() { 524 | interrupt := make(chan os.Signal, 1) 525 | signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) 526 | 527 | <-interrupt 528 | 529 | shutdown = true 530 | fmt.Println("\nShutting down...") 531 | 532 | if child != nil { 533 | killFork(child) 534 | os.Exit(0) 535 | } 536 | }() 537 | 538 | hup := make(chan os.Signal, 1) 539 | signal.Notify(hup, syscall.SIGHUP) 540 | 541 | for { 542 | if child != nil { 543 | killFork(child) 544 | } 545 | 546 | if shutdown { 547 | break 548 | } 549 | 550 | cmd := createFork() 551 | 552 | go func(cmd *exec.Cmd) { 553 | if err := cmd.Wait(); err != nil { // Quit if child exits with abnormal status 554 | fmt.Printf("EXITING (abnormal child exit: %v)", err) 555 | os.Exit(1) 556 | } else if cmd == child { 557 | hup <- syscall.SIGHUP 558 | } 559 | }(cmd) 560 | 561 | child = cmd 562 | 563 | // Prevent another reload from immediately following the previous one 564 | time.Sleep(500 * time.Millisecond) 565 | 566 | <-hup 567 | 568 | fmt.Println("\nReloading...") 569 | fmt.Println("") 570 | } 571 | } 572 | 573 | // errorLogger wraps logrus logger so that we can pass it into the handlers 574 | type errorLogger struct { 575 | log *log.Logger 576 | } 577 | 578 | // It implements the required io.Writer interface 579 | func (el *errorLogger) Write(p []byte) (n int, err error) { 580 | el.log.Errorln(string(p)) 581 | return len(p), nil 582 | } 583 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mbtileserver 2 | 3 | A simple Go-based server for map tiles stored in [mbtiles](https://github.com/mapbox/mbtiles-spec) 4 | format. 5 | 6 | ![Build Status](https://github.com/consbio/mbtileserver/actions/workflows/test.yml/badge.svg) 7 | [![Coverage Status](https://coveralls.io/repos/github/consbio/mbtileserver/badge.svg?branch=master)](https://coveralls.io/github/consbio/mbtileserver?branch=master) 8 | [![GoDoc](https://godoc.org/github.com/consbio/mbtileserver?status.svg)](http://godoc.org/github.com/consbio/mbtileserver) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/consbio/mbtileserver)](https://goreportcard.com/report/github.com/consbio/mbtileserver) 10 | 11 | It currently provides support for `png`, `jpg`, `webp`, and `pbf` (vector tile) 12 | tilesets according to version 1.0 of the mbtiles specification. Tiles 13 | are served following the XYZ tile scheme, based on the Web Mercator 14 | coordinate reference system. UTF8 Grids are no longer supported. 15 | 16 | In addition to tile-level access, it provides: 17 | 18 | - TileJSON 2.1.0 endpoint for each tileset, with full metadata 19 | from the mbtiles file. 20 | - a preview map for exploring each tileset. 21 | - a minimal ArcGIS tile map service API 22 | 23 | We have been able to host a bunch of tilesets on an 24 | [AWS t2.nano](https://aws.amazon.com/about-aws/whats-new/2015/12/introducing-t2-nano-the-smallest-lowest-cost-amazon-ec2-instance/) 25 | virtual machine without any issues. 26 | 27 | ## Goals 28 | 29 | - Provide a web tile API for map tiles stored in mbtiles format 30 | - Be fast 31 | - Run on small resource cloud hosted machines (limited memory & CPU) 32 | - Be easy to install and operate 33 | 34 | ## Supported Go versions 35 | 36 | _Requires Go >= 1.21+._ 37 | 38 | `mbtileserver` uses go modules and follows standard practices as of Go 1.21. 39 | 40 | ## Installation 41 | 42 | You can install this project with 43 | 44 | ```sh 45 | go install github.com/consbio/mbtileserver@latest 46 | ``` 47 | 48 | This will create and install an executable called `mbtileserver`. 49 | 50 | ## Usage 51 | 52 | From within the repository root ($GOPATH/bin needs to be in your $PATH): 53 | 54 | ``` 55 | $ mbtileserver --help 56 | Serve tiles from mbtiles files 57 | 58 | Usage: 59 | mbtileserver [flags] 60 | 61 | Flags: 62 | --basemap-style-url string Basemap style URL for preview endpoint (can include authorization token parameter if required by host) 63 | --basemap-tiles-url string Basemap raster tiles URL pattern for preview endpoint (can include authorization token parameter if required by host): https://some.host/{z}/{x}/{y}.png 64 | -c, --cert string X.509 TLS certificate filename. If present, will be used to enable SSL on the server. 65 | -d, --dir string Directory containing mbtiles files. Can be a comma-delimited list of directories. (default "./tilesets") 66 | --disable-preview Disable map preview for each tileset (enabled by default) 67 | --disable-svc-list Disable services list endpoint (enabled by default) 68 | --disable-tilejson Disable TileJSON endpoint for each tileset (enabled by default) 69 | --domain string Domain name of this server. NOTE: only used for Auto TLS. 70 | --dsn string Sentry DSN 71 | --enable-arcgis Enable ArcGIS Mapserver endpoints 72 | --enable-fs-watch Enable reloading of tilesets by watching filesystem 73 | --enable-reload-signal Enable graceful reload using HUP signal to the server process 74 | --generate-ids Automatically generate tileset IDs instead of using relative path 75 | -h, --help help for mbtileserver 76 | --host string IP address to listen on. Default is all interfaces. (default "0.0.0.0") 77 | -k, --key string TLS private key 78 | --missing-image-tile-404 Return HTTP 404 error code when image tile is misssing instead of default behavior to return blank PNG 79 | -p, --port int Server port. Default is 443 if --cert or --tls options are used, otherwise 8000. (default -1) 80 | -r, --redirect Redirect HTTP to HTTPS 81 | --root-url string Root URL of services endpoint (default "/services") 82 | -s, --secret-key string Shared secret key used for HMAC request authentication 83 | --tiles-only Only enable tile endpoints (shortcut for --disable-svc-list --disable-tilejson --disable-preview) 84 | -t, --tls Auto TLS via Let's Encrypt. Requires domain to be set 85 | -v, --verbose Verbose logging 86 | ``` 87 | 88 | So hosting tiles is as easy as putting your mbtiles files in the `tilesets` 89 | directory and starting the server. Woo hoo! 90 | 91 | You can have multiple directories in your `tilesets` directory; these will be converted into appropriate URLs: 92 | 93 | `/foo/bar/baz.mbtiles` will be available at `/services/foo/bar/baz`. 94 | 95 | If `--generate-ids` is provided, tileset IDs are automatically generated using a SHA1 hash of the path to each tileset. 96 | By default, tileset IDs are based on the relative path of each tileset to the base directory provided using `--dir`. 97 | 98 | When you want to remove, modify, or add new tilesets, simply restart the server process or use one of the reloading processes below. 99 | 100 | If a valid Sentry DSN is provided, warnings, errors, fatal errors, and panics will be reported to Sentry. 101 | 102 | If `redirect` option is provided, the server also listens on port 80 and redirects to port 443. 103 | 104 | If the `--tls` option is provided, the Let's Encrypt Terms of Service are accepted automatically on your behalf. Please review them [here](https://letsencrypt.org/repository/). Certificates are cached in a `.certs` folder created where you are executing `mbtileserver`. Please make sure this folder can be written by the `mbtileserver` process or you will get errors. Certificates are not requested until the first request is made to the server. We recommend that you initialize these after startup by making a request against `https:///services` and watching the logs from the server to make sure that certificates were processed correctly. Common errors include Let's Encrypt not being able to access your server at the domain you provided. `localhost` or internal-only domains will not work. 105 | 106 | If either `--cert` or `--tls` are provided, the default port is 443. 107 | 108 | You can also use environment variables instead of flags, which may be more helpful when deploying in a docker image. Use the associated flag to determine usage. The following variables are available: 109 | 110 | - `PORT` (`--port`) 111 | - `TILE_DIR` (`--dir`) 112 | - `GENERATE_IDS` (`--generate-ids`) 113 | - `ROOT_URL` (`--root-url`) 114 | - `DOMAIN` (`--domain`) 115 | - `TLS_CERT` (`--cert`) 116 | - `TLS_PRIVATE_KEY` (`--key`) 117 | - `HMAC_SECRET_KEY` (`--secret-key`) 118 | - `AUTO_TLS` (`--tls`) 119 | - `REDIRECT` (`--redirect`) 120 | - `DSN` (`--dsn`) 121 | - `VERBOSE` (`--verbose`) 122 | 123 | Example: 124 | 125 | ``` 126 | PORT=7777 TILE_DIR=./path/to/your/tiles VERBOSE=true mbtileserver 127 | ``` 128 | 129 | In a docker-compose.yml file it will look like: 130 | 131 | ``` 132 | mbtileserver: 133 | ... 134 | 135 | environment: 136 | PORT: 7777 137 | TILE_DIR: "./path/to/your/tiles" 138 | VERBOSE: true 139 | entrypoint: mbtileserver 140 | 141 | ... 142 | ``` 143 | 144 | ### Reloading 145 | 146 | #### Reload using a signal 147 | 148 | mbtileserver optionally supports graceful reload (without interrupting any in-progress requests). This functionality 149 | must be enabled with the `--enable-reload-signal` flag. When enabled, the server can be reloaded by sending it a `HUP` signal: 150 | 151 | ``` 152 | kill -HUP 153 | ``` 154 | 155 | Reloading the server will cause it to pick up changes to the tiles directory, adding new tilesets and removing any that 156 | are no longer present. 157 | 158 | #### Reload using a filesystem watcher 159 | 160 | mbtileserver optionally supports reload of individual tilesets by watching for filesystem changes. This functionality 161 | must be enabled with the `--enable-fs-watch` flag. 162 | 163 | All directories specified by `-d` / `--dir` and any subdirectories that exist at the time the server is started 164 | will be watched for changes to the tilesets. 165 | 166 | An existing tileset that is being updated will be locked while the file on disk 167 | is being updated. This will cause incoming requests to that tileset to stall 168 | for up to 30 seconds and will return as soon as the tileset is completely updated 169 | and unlocked. If it takes longer than 30 seconds for the tileset to be updated, 170 | HTTP 503 errors will be returned for that tileset until the tileset is completely 171 | updated and unlocked. 172 | 173 | Under very high request volumes, requests that come in between when the file is 174 | first modified and when that modification is first detected (and tileset locked) 175 | may encounter errors. 176 | 177 | WARNING: Do not remove the top-level watched directories while the server is running. 178 | 179 | WARNING: Do not create or delete subdirectories within the watched directories while the server is running. 180 | 181 | WARNING: do not generate tiles directly in the watched directories. Instead, create them in separate directories and 182 | copy them into the watched directories when complete. 183 | 184 | ### Using with a reverse proxy 185 | 186 | You can use a reverse proxy in front of `mbtileserver` to intercept incoming requests, provide TLS, etc. 187 | 188 | We have used both [`Caddy`](https://caddyserver.com/) and [`NGINX`](https://www.nginx.com/) for our production setups in various projects, 189 | usually when we need to proxy to additional backend services. 190 | 191 | To make sure that the correct request URL is passed to `mbtileserver` so that TileJSON and map preview endpoints work correctly, 192 | make sure to have your reverse proxy send the following headers: 193 | 194 | Scheme (HTTP vs HTTPS): 195 | one of `X-Forwarded-Proto`, `X-Forwarded-Protocol`, `X-Url-Scheme` to set the scheme of the request. 196 | OR 197 | `X-Forwarded-Ssl` to automatically set the scheme to HTTPS. 198 | 199 | Host: 200 | Set `X-Forwarded-Host` to the correct host for the request. 201 | 202 | #### Caddy v2 Example: 203 | 204 | For `mbtileserver` running on port 8000 locally, add the following to the block for your domain name: 205 | 206 | ``` 207 | { 208 | route /services* { 209 | reverse_proxy localhost:8000 210 | } 211 | } 212 | ``` 213 | 214 | You may want to consider adding cache control headers within the `route` block 215 | depending on how often the contents of your tilesets change. For instance, 216 | to prevent clients from caching tiles longer than 1 hour: 217 | 218 | ``` 219 | route /services* { 220 | header Cache-Control "public, max-age=3600, must-revalidate" 221 | localhost mbtileserver:8000 222 | } 223 | ``` 224 | 225 | #### NGINX Example: 226 | 227 | For `mbtileserver` running on port 8000 locally, add the following to your `server` block: 228 | 229 | ``` 230 | server { 231 | 232 | 233 | location /services { 234 | proxy_set_header Host $http_host; 235 | proxy_set_header X-Forwarded-Proto $scheme; 236 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 237 | proxy_set_header X-Real-IP $remote_addr; 238 | proxy_set_header X-Forwarded-Ssl on; 239 | proxy_pass http://localhost:8000; 240 | } 241 | } 242 | ``` 243 | 244 | ## Docker 245 | 246 | Pull the latest image from 247 | [Github Container Registry](https://github.com/consbio/mbtileserver/pkgs/container/mbtileserver): 248 | 249 | ``` 250 | docker pull ghcr.io/consbio/mbtileserver:latest 251 | ``` 252 | 253 | To build the Docker image locally (named `mbtileserver`): 254 | 255 | ``` 256 | docker build -t mbtileserver -f Dockerfile . 257 | ``` 258 | 259 | To run the Docker container on port 8080 with your tilesets in ``. 260 | Note that by default, `mbtileserver` runs on port 8000 in the container. 261 | 262 | ``` 263 | docker run --rm -p 8080:8000 -v :/tilesets ghcr.io/consbio/mbtileserver:latest 264 | ``` 265 | 266 | You can pass in additional command-line arguments to `mbtileserver`, for example, to use 267 | certificates and files in `` so that you can access the server via HTTPS. The example below uses self-signed certificates generated using 268 | [`mkcert`](https://github.com/FiloSottile/mkcert). This example uses automatic redirects, which causes `mbtileserver` to also listen on port 80 and automatically redirect to 443. 269 | 270 | ``` 271 | docker run --rm -p 80:80 -p 443:443 -v :/tilesets -v :/certs/ ghcr.io/consbio/mbtileserver:latest -c /certs/localhost.pem -k /certs/localhost-key.pem -p 443 --redirect 272 | ``` 273 | 274 | Alternately, use `docker-compose` to run: 275 | 276 | ``` 277 | docker-compose up -d 278 | ``` 279 | 280 | The default `docker-compose.yml` configures `mbtileserver` to connect to port 8080 on the host, and uses the `./mbtiles/testdata` folder for tilesets. You can use your own `docker-compose.override.yml` or [environment specific files](https://docs.docker.com/compose/extends/) to set these how you like. 281 | 282 | To reload the server: 283 | 284 | ``` 285 | docker exec -it mbtileserver sh -c "kill -HUP 1" 286 | ``` 287 | 288 | ## Specifications 289 | 290 | - expects mbtiles files to follow version 1.0 of the [mbtiles specification](https://github.com/mapbox/mbtiles-spec). Version 1.1 is preferred. 291 | - implements [TileJSON 2.1.0](https://github.com/mapbox/tilejson-spec) 292 | 293 | ## Creating Tiles 294 | 295 | You can create mbtiles files using a variety of tools. We have created 296 | tiles for use with mbtileserver using: 297 | 298 | - [TileMill](https://www.mapbox.com/tilemill/) (image tiles) 299 | - [tippecanoe](https://github.com/mapbox/tippecanoe) (vector tiles) 300 | - [pymbtiles](https://github.com/consbio/pymbtiles) (tiles created using Python) 301 | - [tpkutils](https://github.com/consbio/tpkutils) (image tiles from ArcGIS tile packages) 302 | 303 | The root name of each mbtiles file becomes the "tileset_id" as used below. 304 | 305 | ## XYZ Tile API 306 | 307 | The primary use of `mbtileserver` is as a host for XYZ tiles. 308 | 309 | These are provided at: 310 | `/services//tiles/{z}/{x}/{y}.` 311 | 312 | where `` is one of `png`, `jpg`, `webp`, `pbf` depending on the type of data in the tileset. 313 | 314 | 315 | ### Missing tiles 316 | 317 | Missing vector tiles are always returned as HTTP 204. 318 | 319 | Missing image tiles are returned as blank PNGs with the same dimensions as the tileset to give seamless display of 320 | these tiles in interactive maps. 321 | 322 | When serving image tiles that encode data (e.g., terrain) instead of purely for display, this can cause issues. In 323 | this case, you can use the `--missing-image-tile-404` option. This behavior will be applied to all image tilesets. 324 | 325 | 326 | ## TileJSON API 327 | 328 | `mbtileserver` automatically creates a TileJSON endpoint for each service at `/services/`. 329 | The TileJSON uses the same scheme and domain name as is used for the incoming request; the `--domain` setting does not 330 | affect auto-generated URLs. 331 | 332 | This API provides most elements of the `metadata` table in the mbtiles file as well as others that are 333 | automatically inferred from tile data. 334 | 335 | For example, 336 | `http://localhost/services/states_outline` 337 | 338 | returns something like this: 339 | 340 | ``` 341 | { 342 | "bounds": [ 343 | -179.23108, 344 | -14.601813, 345 | 179.85968, 346 | 71.441055 347 | ], 348 | "center": [ 349 | 0.314297, 350 | 28.419622, 351 | 1 352 | ], 353 | "credits": "US Census Bureau", 354 | "description": "States", 355 | "format": "png", 356 | "id": "states_outline", 357 | "legend": "[{\"elements\": [{\"label\": \"\", \"imageData\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAGFJREFUOI3tlDEOgEAIBClI5kF+w0fxwXvQdjZywcZEtDI31YaQgWrdPsYzAPFGJCmmEAhJGzCash0wSVE/HHnlKcDMfrPXYgmXcAl/JswK6lCrz89BdGVm1+qrH0bbWDgA3WwmgzD8ueEAAAAASUVORK5CYII=\"}], \"name\": \"tl_2015_us_state\"}]", 358 | "map": "http://localhost/services/states_outline/map", 359 | "maxzoom": 4, 360 | "minzoom": 0, 361 | "name": "states_outline", 362 | "scheme": "xyz", 363 | "tags": "states", 364 | "tilejson": "2.1.0", 365 | "tiles": [ 366 | "http://localhost/services/states_outline/tiles/{z}/{x}/{y}.png" 367 | ], 368 | "type": "overlay", 369 | "version": "1.0.0" 370 | } 371 | ``` 372 | 373 | ## Map preview 374 | 375 | `mbtileserver` automatically creates a map preview page for each tileset at `/services//map`. 376 | 377 | It uses `MapLibre GL` to render vector and image tiles. 378 | 379 | No built-in basemap is included by default in the map preview. You can use 380 | one of the following options to include a basemap. 381 | 382 | ### Basemap style URL 383 | 384 | To include a [MapLibre GL style URL](https://maplibre.org/maplibre-style-spec/) 385 | use the `--basemap-style-url` option to provide a URL to that style: 386 | 387 | ``` 388 | --basemap-style-url "https://tiles.stadiamaps.com/styles/stamen_toner_lite.json?api_key=" 389 | ``` 390 | 391 | The URL can include query parameters as required by the host, such as 392 | `?access_token=`. 393 | 394 | 395 | ### Basemap tiles URL 396 | 397 | To include a basemap based on image tile URLs, use the `--basemap-tiles-url` 398 | option to provide a raster tile URL pattern: 399 | 400 | ``` 401 | --basemap https://some.host/{z}/{x}/{y}.png 402 | ``` 403 | 404 | The template parameters `{z}` (zoom), `{x}`, `{y}` are required. 405 | 406 | The extension can be omitted or be any image format supported by MapLibre GL. 407 | 408 | The URL can include query parameters as required by the host, such as 409 | `?access_token=`. 410 | 411 | IMPORTANT: this does not support vector tiles. 412 | 413 | 414 | ## ArcGIS API 415 | 416 | This project currently provides a minimal ArcGIS tiled map service API for tiles stored in an mbtiles file. 417 | 418 | This is enabled with the `--enable-arcgis` flag. 419 | 420 | This should be sufficient for use with online platforms such as [Data Basin](https://databasin.org). Because the ArcGIS API relies on a number of properties that are not commonly available within an mbtiles file, so certain aspects are stubbed out with minimal information. 421 | 422 | This API is not intended for use with more full-featured ArcGIS applications such as ArcGIS Desktop. 423 | 424 | Available endpoints: 425 | 426 | - Service info: `http://localhost:8000/arcgis/rest/services//MapServer` 427 | - Layer info: `http://localhost:8000/arcgis/rest/services//MapServer/layers` 428 | - Tiles: `http://localhost:8000/arcgis/rest/services//MapServer/tile/0/0/0` 429 | 430 | ## Request authorization 431 | 432 | Providing a secret key with `-s/--secret-key` or by setting the `HMAC_SECRET_KEY` environment variable will 433 | restrict access to all server endpoints and tile requests. Requests will only be served if they provide a cryptographic 434 | signature created using the same secret key. This allows, for example, an application server to provide authorized 435 | clients a short-lived token with which the clients can access tiles for a specific service. 436 | 437 | Signatures expire 15 minutes from their creation date to prevent exposed or leaked signatures from being useful past a 438 | small time window. 439 | 440 | ### Creating signatures 441 | 442 | A signature is a URL-safe, base64 encoded HMAC hash using the `SHA1` algorithm. The hash key is an `SHA1` key created 443 | from a randomly generated salt, and the **secret key** string. The hash payload is a combination of the ISO-formatted 444 | date when the hash was created, and the authorized service id. 445 | 446 | The following is an example signature, created in Go for the service id `test`, the date 447 | `2019-03-08T19:31:12.213831+00:00`, the salt `0EvkK316T-sBLA`, and the secret key 448 | `YMIVXikJWAiiR3q-JMz1v2Mfmx3gTXJVNqme5kyaqrY` 449 | 450 | Create the SHA1 key: 451 | 452 | ```go 453 | serviceId := "test" 454 | date := "2019-03-08T19:31:12.213831+00:00" 455 | salt := "0EvkK316T-sBLA" 456 | secretKey := "YMIVXikJWAiiR3q-JMz1v2Mfmx3gTXJVNqme5kyaqrY" 457 | 458 | key := sha1.New() 459 | key.Write([]byte(salt + secretKey)) 460 | ``` 461 | 462 | Create the signature hash: 463 | 464 | ```go 465 | hash := hmac.New(sha1.New, key.Sum(nil)) 466 | message := fmt.Sprintf("%s:%s", date, serviceId) 467 | hash.Write([]byte(message)) 468 | ``` 469 | 470 | Finally, base64-encode the hash: 471 | 472 | ```go 473 | b64hash := base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) 474 | fmt.Println(b64hash) // Should output: 2y8vHb9xK6RSxN8EXMeAEUiYtZk 475 | ``` 476 | 477 | ### Making request 478 | 479 | Authenticated requests must include the ISO-formatted date, and a salt-signature combination in the form of: 480 | `:`. These can be provided as query parameters: 481 | 482 | ```text 483 | ?date=2019-03-08T19:31:12.213831%2B00:00&signature=0EvkK316T-sBLA:YMIVXikJWAiiR3q-JMz1v2Mfmx3gTXJVNqme5kyaqrY 484 | ``` 485 | 486 | Or they can be provided as request headers: 487 | 488 | ```text 489 | X-Signature-Date: 2019-03-08T19:31:12.213831+00:00 490 | X-Signature: 0EvkK316T-sBLA:YMIVXikJWAiiR3q-JMz1v2Mfmx3gTXJVNqme5kyaqrY 491 | ``` 492 | 493 | ## Development 494 | 495 | Dependencies are managed using go modules. Vendored dependencies are stored in `vendor` folder by using `go mod vendor`. 496 | 497 | On Windows, it is necessary to install `gcc` in order to compile `mattn/go-sqlite3`. 498 | MinGW or [TDM-GCC](https://sourceforge.net/projects/tdm-gcc/) should work fine. 499 | 500 | If you experience very slow builds each time, it may be that you need to first run 501 | 502 | ``` 503 | go build -a . 504 | ``` 505 | 506 | to make subsequent builds much faster. 507 | 508 | Development of the templates and static assets requires using 509 | NodeJS 20 and `npm`. Install these tools in the normal way. 510 | 511 | From the `handlers/templates/static` folder, run 512 | 513 | ```bash 514 | npm install 515 | ``` 516 | 517 | to pull in the static dependencies. These are referenced in the 518 | `package.json` file. 519 | 520 | Then to build the minified version, run: 521 | 522 | ```bash 523 | npm run build 524 | ``` 525 | 526 | Built static assets are saved to `handlers/templates/static/dist` and included 527 | via `go:embed` into the final executable. 528 | 529 | Modifying the `.go` files or anything under `handlers/templates` always requires 530 | re-running `go build .`. 531 | 532 | ## Changes 533 | 534 | See [CHANGELOG](CHANGELOG.md). 535 | 536 | ## Contributors ✨ 537 | 538 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 |

Brendan Ward

💻 📖 🐛 📝 👀 🤔

Fabian Wickborn

💻 📖 🐛 🤔

Nik Molnar

💻 🤔 🐛

Nikolay Korotkiy

💻 🐛

Robert Brown

💻

Mihail

💻

Marko Burjek

💻

Kristjan

💻

evbarnett

🐛

walkaholic.me

🐛

Brian Voelker

🐛

Georg Leciejewski

🐛

Christophe Benz

🐛

Malcolm Meyer

🐛

Josh Lee

💻

Martin Karlsen Jensen

💻
567 | 568 | 569 | 570 | 571 | 572 | 573 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 574 | -------------------------------------------------------------------------------- /handlers/templates/static/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mbtileserver", 3 | "version": "0.11.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "mbtileserver", 9 | "version": "0.11.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@mapbox/geo-viewport": "^0.5.0", 13 | "esbuild": "^0.24.0", 14 | "maplibre-gl": "^4.7.1" 15 | } 16 | }, 17 | "node_modules/@esbuild/aix-ppc64": { 18 | "version": "0.24.0", 19 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", 20 | "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", 21 | "cpu": [ 22 | "ppc64" 23 | ], 24 | "optional": true, 25 | "os": [ 26 | "aix" 27 | ], 28 | "engines": { 29 | "node": ">=18" 30 | } 31 | }, 32 | "node_modules/@esbuild/android-arm": { 33 | "version": "0.24.0", 34 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", 35 | "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", 36 | "cpu": [ 37 | "arm" 38 | ], 39 | "optional": true, 40 | "os": [ 41 | "android" 42 | ], 43 | "engines": { 44 | "node": ">=18" 45 | } 46 | }, 47 | "node_modules/@esbuild/android-arm64": { 48 | "version": "0.24.0", 49 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", 50 | "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", 51 | "cpu": [ 52 | "arm64" 53 | ], 54 | "optional": true, 55 | "os": [ 56 | "android" 57 | ], 58 | "engines": { 59 | "node": ">=18" 60 | } 61 | }, 62 | "node_modules/@esbuild/android-x64": { 63 | "version": "0.24.0", 64 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", 65 | "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", 66 | "cpu": [ 67 | "x64" 68 | ], 69 | "optional": true, 70 | "os": [ 71 | "android" 72 | ], 73 | "engines": { 74 | "node": ">=18" 75 | } 76 | }, 77 | "node_modules/@esbuild/darwin-arm64": { 78 | "version": "0.24.0", 79 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", 80 | "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", 81 | "cpu": [ 82 | "arm64" 83 | ], 84 | "optional": true, 85 | "os": [ 86 | "darwin" 87 | ], 88 | "engines": { 89 | "node": ">=18" 90 | } 91 | }, 92 | "node_modules/@esbuild/darwin-x64": { 93 | "version": "0.24.0", 94 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", 95 | "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", 96 | "cpu": [ 97 | "x64" 98 | ], 99 | "optional": true, 100 | "os": [ 101 | "darwin" 102 | ], 103 | "engines": { 104 | "node": ">=18" 105 | } 106 | }, 107 | "node_modules/@esbuild/freebsd-arm64": { 108 | "version": "0.24.0", 109 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", 110 | "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", 111 | "cpu": [ 112 | "arm64" 113 | ], 114 | "optional": true, 115 | "os": [ 116 | "freebsd" 117 | ], 118 | "engines": { 119 | "node": ">=18" 120 | } 121 | }, 122 | "node_modules/@esbuild/freebsd-x64": { 123 | "version": "0.24.0", 124 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", 125 | "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", 126 | "cpu": [ 127 | "x64" 128 | ], 129 | "optional": true, 130 | "os": [ 131 | "freebsd" 132 | ], 133 | "engines": { 134 | "node": ">=18" 135 | } 136 | }, 137 | "node_modules/@esbuild/linux-arm": { 138 | "version": "0.24.0", 139 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", 140 | "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", 141 | "cpu": [ 142 | "arm" 143 | ], 144 | "optional": true, 145 | "os": [ 146 | "linux" 147 | ], 148 | "engines": { 149 | "node": ">=18" 150 | } 151 | }, 152 | "node_modules/@esbuild/linux-arm64": { 153 | "version": "0.24.0", 154 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", 155 | "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", 156 | "cpu": [ 157 | "arm64" 158 | ], 159 | "optional": true, 160 | "os": [ 161 | "linux" 162 | ], 163 | "engines": { 164 | "node": ">=18" 165 | } 166 | }, 167 | "node_modules/@esbuild/linux-ia32": { 168 | "version": "0.24.0", 169 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", 170 | "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", 171 | "cpu": [ 172 | "ia32" 173 | ], 174 | "optional": true, 175 | "os": [ 176 | "linux" 177 | ], 178 | "engines": { 179 | "node": ">=18" 180 | } 181 | }, 182 | "node_modules/@esbuild/linux-loong64": { 183 | "version": "0.24.0", 184 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", 185 | "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", 186 | "cpu": [ 187 | "loong64" 188 | ], 189 | "optional": true, 190 | "os": [ 191 | "linux" 192 | ], 193 | "engines": { 194 | "node": ">=18" 195 | } 196 | }, 197 | "node_modules/@esbuild/linux-mips64el": { 198 | "version": "0.24.0", 199 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", 200 | "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", 201 | "cpu": [ 202 | "mips64el" 203 | ], 204 | "optional": true, 205 | "os": [ 206 | "linux" 207 | ], 208 | "engines": { 209 | "node": ">=18" 210 | } 211 | }, 212 | "node_modules/@esbuild/linux-ppc64": { 213 | "version": "0.24.0", 214 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", 215 | "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", 216 | "cpu": [ 217 | "ppc64" 218 | ], 219 | "optional": true, 220 | "os": [ 221 | "linux" 222 | ], 223 | "engines": { 224 | "node": ">=18" 225 | } 226 | }, 227 | "node_modules/@esbuild/linux-riscv64": { 228 | "version": "0.24.0", 229 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", 230 | "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", 231 | "cpu": [ 232 | "riscv64" 233 | ], 234 | "optional": true, 235 | "os": [ 236 | "linux" 237 | ], 238 | "engines": { 239 | "node": ">=18" 240 | } 241 | }, 242 | "node_modules/@esbuild/linux-s390x": { 243 | "version": "0.24.0", 244 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", 245 | "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", 246 | "cpu": [ 247 | "s390x" 248 | ], 249 | "optional": true, 250 | "os": [ 251 | "linux" 252 | ], 253 | "engines": { 254 | "node": ">=18" 255 | } 256 | }, 257 | "node_modules/@esbuild/linux-x64": { 258 | "version": "0.24.0", 259 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", 260 | "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", 261 | "cpu": [ 262 | "x64" 263 | ], 264 | "optional": true, 265 | "os": [ 266 | "linux" 267 | ], 268 | "engines": { 269 | "node": ">=18" 270 | } 271 | }, 272 | "node_modules/@esbuild/netbsd-x64": { 273 | "version": "0.24.0", 274 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", 275 | "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", 276 | "cpu": [ 277 | "x64" 278 | ], 279 | "optional": true, 280 | "os": [ 281 | "netbsd" 282 | ], 283 | "engines": { 284 | "node": ">=18" 285 | } 286 | }, 287 | "node_modules/@esbuild/openbsd-arm64": { 288 | "version": "0.24.0", 289 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", 290 | "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", 291 | "cpu": [ 292 | "arm64" 293 | ], 294 | "optional": true, 295 | "os": [ 296 | "openbsd" 297 | ], 298 | "engines": { 299 | "node": ">=18" 300 | } 301 | }, 302 | "node_modules/@esbuild/openbsd-x64": { 303 | "version": "0.24.0", 304 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", 305 | "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", 306 | "cpu": [ 307 | "x64" 308 | ], 309 | "optional": true, 310 | "os": [ 311 | "openbsd" 312 | ], 313 | "engines": { 314 | "node": ">=18" 315 | } 316 | }, 317 | "node_modules/@esbuild/sunos-x64": { 318 | "version": "0.24.0", 319 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", 320 | "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", 321 | "cpu": [ 322 | "x64" 323 | ], 324 | "optional": true, 325 | "os": [ 326 | "sunos" 327 | ], 328 | "engines": { 329 | "node": ">=18" 330 | } 331 | }, 332 | "node_modules/@esbuild/win32-arm64": { 333 | "version": "0.24.0", 334 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", 335 | "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", 336 | "cpu": [ 337 | "arm64" 338 | ], 339 | "optional": true, 340 | "os": [ 341 | "win32" 342 | ], 343 | "engines": { 344 | "node": ">=18" 345 | } 346 | }, 347 | "node_modules/@esbuild/win32-ia32": { 348 | "version": "0.24.0", 349 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", 350 | "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", 351 | "cpu": [ 352 | "ia32" 353 | ], 354 | "optional": true, 355 | "os": [ 356 | "win32" 357 | ], 358 | "engines": { 359 | "node": ">=18" 360 | } 361 | }, 362 | "node_modules/@esbuild/win32-x64": { 363 | "version": "0.24.0", 364 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", 365 | "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", 366 | "cpu": [ 367 | "x64" 368 | ], 369 | "optional": true, 370 | "os": [ 371 | "win32" 372 | ], 373 | "engines": { 374 | "node": ">=18" 375 | } 376 | }, 377 | "node_modules/@mapbox/geo-viewport": { 378 | "version": "0.5.0", 379 | "resolved": "https://registry.npmjs.org/@mapbox/geo-viewport/-/geo-viewport-0.5.0.tgz", 380 | "integrity": "sha512-h0b10JU+lSxw8/TLXGdzcVTPxMN9Ikv0os8sCo0OAHXUiSDkQs5fx4WWLJeQTnC++qaGFl6/Ssr+H5N6NIvE5g==", 381 | "dependencies": { 382 | "@mapbox/sphericalmercator": "^1.2.0" 383 | } 384 | }, 385 | "node_modules/@mapbox/geojson-rewind": { 386 | "version": "0.5.2", 387 | "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", 388 | "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", 389 | "dependencies": { 390 | "get-stream": "^6.0.1", 391 | "minimist": "^1.2.6" 392 | }, 393 | "bin": { 394 | "geojson-rewind": "geojson-rewind" 395 | } 396 | }, 397 | "node_modules/@mapbox/jsonlint-lines-primitives": { 398 | "version": "2.0.2", 399 | "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", 400 | "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", 401 | "engines": { 402 | "node": ">= 0.6" 403 | } 404 | }, 405 | "node_modules/@mapbox/point-geometry": { 406 | "version": "0.1.0", 407 | "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", 408 | "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==" 409 | }, 410 | "node_modules/@mapbox/sphericalmercator": { 411 | "version": "1.2.0", 412 | "resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.2.0.tgz", 413 | "integrity": "sha512-ZTOuuwGuMOJN+HEmG/68bSEw15HHaMWmQ5gdTsWdWsjDe56K1kGvLOK6bOSC8gWgIvEO0w6un/2Gvv1q5hJSkQ==", 414 | "bin": { 415 | "bbox": "bin/bbox.js", 416 | "to4326": "bin/to4326.js", 417 | "to900913": "bin/to900913.js", 418 | "xyz": "bin/xyz.js" 419 | } 420 | }, 421 | "node_modules/@mapbox/tiny-sdf": { 422 | "version": "2.0.6", 423 | "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", 424 | "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==" 425 | }, 426 | "node_modules/@mapbox/unitbezier": { 427 | "version": "0.0.1", 428 | "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", 429 | "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==" 430 | }, 431 | "node_modules/@mapbox/vector-tile": { 432 | "version": "1.3.1", 433 | "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", 434 | "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", 435 | "dependencies": { 436 | "@mapbox/point-geometry": "~0.1.0" 437 | } 438 | }, 439 | "node_modules/@mapbox/whoots-js": { 440 | "version": "3.1.0", 441 | "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", 442 | "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", 443 | "engines": { 444 | "node": ">=6.0.0" 445 | } 446 | }, 447 | "node_modules/@maplibre/maplibre-gl-style-spec": { 448 | "version": "20.3.1", 449 | "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.3.1.tgz", 450 | "integrity": "sha512-5ueL4UDitzVtceQ8J4kY+Px3WK+eZTsmGwha3MBKHKqiHvKrjWWwBCIl1K8BuJSc5OFh83uI8IFNoFvQxX2uUw==", 451 | "dependencies": { 452 | "@mapbox/jsonlint-lines-primitives": "~2.0.2", 453 | "@mapbox/unitbezier": "^0.0.1", 454 | "json-stringify-pretty-compact": "^4.0.0", 455 | "minimist": "^1.2.8", 456 | "quickselect": "^2.0.0", 457 | "rw": "^1.3.3", 458 | "sort-object": "^3.0.3", 459 | "tinyqueue": "^3.0.0" 460 | }, 461 | "bin": { 462 | "gl-style-format": "dist/gl-style-format.mjs", 463 | "gl-style-migrate": "dist/gl-style-migrate.mjs", 464 | "gl-style-validate": "dist/gl-style-validate.mjs" 465 | } 466 | }, 467 | "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { 468 | "version": "2.0.0", 469 | "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", 470 | "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" 471 | }, 472 | "node_modules/@types/geojson": { 473 | "version": "7946.0.14", 474 | "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", 475 | "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" 476 | }, 477 | "node_modules/@types/geojson-vt": { 478 | "version": "3.2.5", 479 | "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", 480 | "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", 481 | "dependencies": { 482 | "@types/geojson": "*" 483 | } 484 | }, 485 | "node_modules/@types/mapbox__point-geometry": { 486 | "version": "0.1.4", 487 | "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", 488 | "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==" 489 | }, 490 | "node_modules/@types/mapbox__vector-tile": { 491 | "version": "1.3.4", 492 | "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", 493 | "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", 494 | "dependencies": { 495 | "@types/geojson": "*", 496 | "@types/mapbox__point-geometry": "*", 497 | "@types/pbf": "*" 498 | } 499 | }, 500 | "node_modules/@types/pbf": { 501 | "version": "3.0.5", 502 | "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", 503 | "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==" 504 | }, 505 | "node_modules/@types/supercluster": { 506 | "version": "7.1.3", 507 | "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", 508 | "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", 509 | "dependencies": { 510 | "@types/geojson": "*" 511 | } 512 | }, 513 | "node_modules/arr-union": { 514 | "version": "3.1.0", 515 | "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", 516 | "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", 517 | "engines": { 518 | "node": ">=0.10.0" 519 | } 520 | }, 521 | "node_modules/assign-symbols": { 522 | "version": "1.0.0", 523 | "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", 524 | "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", 525 | "engines": { 526 | "node": ">=0.10.0" 527 | } 528 | }, 529 | "node_modules/bytewise": { 530 | "version": "1.1.0", 531 | "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", 532 | "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", 533 | "dependencies": { 534 | "bytewise-core": "^1.2.2", 535 | "typewise": "^1.0.3" 536 | } 537 | }, 538 | "node_modules/bytewise-core": { 539 | "version": "1.2.3", 540 | "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", 541 | "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", 542 | "dependencies": { 543 | "typewise-core": "^1.2" 544 | } 545 | }, 546 | "node_modules/earcut": { 547 | "version": "3.0.0", 548 | "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.0.tgz", 549 | "integrity": "sha512-41Fs7Q/PLq1SDbqjsgcY7GA42T0jvaCNGXgGtsNdvg+Yv8eIu06bxv4/PoREkZ9nMDNwnUSG9OFB9+yv8eKhDg==" 550 | }, 551 | "node_modules/esbuild": { 552 | "version": "0.24.0", 553 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", 554 | "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", 555 | "hasInstallScript": true, 556 | "bin": { 557 | "esbuild": "bin/esbuild" 558 | }, 559 | "engines": { 560 | "node": ">=18" 561 | }, 562 | "optionalDependencies": { 563 | "@esbuild/aix-ppc64": "0.24.0", 564 | "@esbuild/android-arm": "0.24.0", 565 | "@esbuild/android-arm64": "0.24.0", 566 | "@esbuild/android-x64": "0.24.0", 567 | "@esbuild/darwin-arm64": "0.24.0", 568 | "@esbuild/darwin-x64": "0.24.0", 569 | "@esbuild/freebsd-arm64": "0.24.0", 570 | "@esbuild/freebsd-x64": "0.24.0", 571 | "@esbuild/linux-arm": "0.24.0", 572 | "@esbuild/linux-arm64": "0.24.0", 573 | "@esbuild/linux-ia32": "0.24.0", 574 | "@esbuild/linux-loong64": "0.24.0", 575 | "@esbuild/linux-mips64el": "0.24.0", 576 | "@esbuild/linux-ppc64": "0.24.0", 577 | "@esbuild/linux-riscv64": "0.24.0", 578 | "@esbuild/linux-s390x": "0.24.0", 579 | "@esbuild/linux-x64": "0.24.0", 580 | "@esbuild/netbsd-x64": "0.24.0", 581 | "@esbuild/openbsd-arm64": "0.24.0", 582 | "@esbuild/openbsd-x64": "0.24.0", 583 | "@esbuild/sunos-x64": "0.24.0", 584 | "@esbuild/win32-arm64": "0.24.0", 585 | "@esbuild/win32-ia32": "0.24.0", 586 | "@esbuild/win32-x64": "0.24.0" 587 | } 588 | }, 589 | "node_modules/extend-shallow": { 590 | "version": "2.0.1", 591 | "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", 592 | "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", 593 | "dependencies": { 594 | "is-extendable": "^0.1.0" 595 | }, 596 | "engines": { 597 | "node": ">=0.10.0" 598 | } 599 | }, 600 | "node_modules/geojson-vt": { 601 | "version": "4.0.2", 602 | "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", 603 | "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==" 604 | }, 605 | "node_modules/get-stream": { 606 | "version": "6.0.1", 607 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", 608 | "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", 609 | "engines": { 610 | "node": ">=10" 611 | }, 612 | "funding": { 613 | "url": "https://github.com/sponsors/sindresorhus" 614 | } 615 | }, 616 | "node_modules/get-value": { 617 | "version": "2.0.6", 618 | "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", 619 | "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", 620 | "engines": { 621 | "node": ">=0.10.0" 622 | } 623 | }, 624 | "node_modules/gl-matrix": { 625 | "version": "3.4.3", 626 | "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", 627 | "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==" 628 | }, 629 | "node_modules/global-prefix": { 630 | "version": "4.0.0", 631 | "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", 632 | "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", 633 | "dependencies": { 634 | "ini": "^4.1.3", 635 | "kind-of": "^6.0.3", 636 | "which": "^4.0.0" 637 | }, 638 | "engines": { 639 | "node": ">=16" 640 | } 641 | }, 642 | "node_modules/ieee754": { 643 | "version": "1.2.1", 644 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 645 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 646 | "funding": [ 647 | { 648 | "type": "github", 649 | "url": "https://github.com/sponsors/feross" 650 | }, 651 | { 652 | "type": "patreon", 653 | "url": "https://www.patreon.com/feross" 654 | }, 655 | { 656 | "type": "consulting", 657 | "url": "https://feross.org/support" 658 | } 659 | ] 660 | }, 661 | "node_modules/ini": { 662 | "version": "4.1.3", 663 | "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", 664 | "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", 665 | "engines": { 666 | "node": "^14.17.0 || ^16.13.0 || >=18.0.0" 667 | } 668 | }, 669 | "node_modules/is-extendable": { 670 | "version": "0.1.1", 671 | "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", 672 | "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", 673 | "engines": { 674 | "node": ">=0.10.0" 675 | } 676 | }, 677 | "node_modules/is-plain-object": { 678 | "version": "2.0.4", 679 | "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", 680 | "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", 681 | "dependencies": { 682 | "isobject": "^3.0.1" 683 | }, 684 | "engines": { 685 | "node": ">=0.10.0" 686 | } 687 | }, 688 | "node_modules/isexe": { 689 | "version": "3.1.1", 690 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", 691 | "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", 692 | "engines": { 693 | "node": ">=16" 694 | } 695 | }, 696 | "node_modules/isobject": { 697 | "version": "3.0.1", 698 | "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", 699 | "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", 700 | "engines": { 701 | "node": ">=0.10.0" 702 | } 703 | }, 704 | "node_modules/json-stringify-pretty-compact": { 705 | "version": "4.0.0", 706 | "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", 707 | "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==" 708 | }, 709 | "node_modules/kdbush": { 710 | "version": "4.0.2", 711 | "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", 712 | "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" 713 | }, 714 | "node_modules/kind-of": { 715 | "version": "6.0.3", 716 | "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", 717 | "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", 718 | "engines": { 719 | "node": ">=0.10.0" 720 | } 721 | }, 722 | "node_modules/maplibre-gl": { 723 | "version": "4.7.1", 724 | "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", 725 | "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", 726 | "dependencies": { 727 | "@mapbox/geojson-rewind": "^0.5.2", 728 | "@mapbox/jsonlint-lines-primitives": "^2.0.2", 729 | "@mapbox/point-geometry": "^0.1.0", 730 | "@mapbox/tiny-sdf": "^2.0.6", 731 | "@mapbox/unitbezier": "^0.0.1", 732 | "@mapbox/vector-tile": "^1.3.1", 733 | "@mapbox/whoots-js": "^3.1.0", 734 | "@maplibre/maplibre-gl-style-spec": "^20.3.1", 735 | "@types/geojson": "^7946.0.14", 736 | "@types/geojson-vt": "3.2.5", 737 | "@types/mapbox__point-geometry": "^0.1.4", 738 | "@types/mapbox__vector-tile": "^1.3.4", 739 | "@types/pbf": "^3.0.5", 740 | "@types/supercluster": "^7.1.3", 741 | "earcut": "^3.0.0", 742 | "geojson-vt": "^4.0.2", 743 | "gl-matrix": "^3.4.3", 744 | "global-prefix": "^4.0.0", 745 | "kdbush": "^4.0.2", 746 | "murmurhash-js": "^1.0.0", 747 | "pbf": "^3.3.0", 748 | "potpack": "^2.0.0", 749 | "quickselect": "^3.0.0", 750 | "supercluster": "^8.0.1", 751 | "tinyqueue": "^3.0.0", 752 | "vt-pbf": "^3.1.3" 753 | }, 754 | "engines": { 755 | "node": ">=16.14.0", 756 | "npm": ">=8.1.0" 757 | }, 758 | "funding": { 759 | "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" 760 | } 761 | }, 762 | "node_modules/minimist": { 763 | "version": "1.2.8", 764 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 765 | "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 766 | "funding": { 767 | "url": "https://github.com/sponsors/ljharb" 768 | } 769 | }, 770 | "node_modules/murmurhash-js": { 771 | "version": "1.0.0", 772 | "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", 773 | "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==" 774 | }, 775 | "node_modules/pbf": { 776 | "version": "3.3.0", 777 | "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", 778 | "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", 779 | "dependencies": { 780 | "ieee754": "^1.1.12", 781 | "resolve-protobuf-schema": "^2.1.0" 782 | }, 783 | "bin": { 784 | "pbf": "bin/pbf" 785 | } 786 | }, 787 | "node_modules/potpack": { 788 | "version": "2.0.0", 789 | "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", 790 | "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" 791 | }, 792 | "node_modules/protocol-buffers-schema": { 793 | "version": "3.6.0", 794 | "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", 795 | "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" 796 | }, 797 | "node_modules/quickselect": { 798 | "version": "3.0.0", 799 | "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", 800 | "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" 801 | }, 802 | "node_modules/resolve-protobuf-schema": { 803 | "version": "2.1.0", 804 | "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", 805 | "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", 806 | "dependencies": { 807 | "protocol-buffers-schema": "^3.3.1" 808 | } 809 | }, 810 | "node_modules/rw": { 811 | "version": "1.3.3", 812 | "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", 813 | "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" 814 | }, 815 | "node_modules/set-value": { 816 | "version": "2.0.1", 817 | "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", 818 | "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", 819 | "dependencies": { 820 | "extend-shallow": "^2.0.1", 821 | "is-extendable": "^0.1.1", 822 | "is-plain-object": "^2.0.3", 823 | "split-string": "^3.0.1" 824 | }, 825 | "engines": { 826 | "node": ">=0.10.0" 827 | } 828 | }, 829 | "node_modules/sort-asc": { 830 | "version": "0.2.0", 831 | "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", 832 | "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", 833 | "engines": { 834 | "node": ">=0.10.0" 835 | } 836 | }, 837 | "node_modules/sort-desc": { 838 | "version": "0.2.0", 839 | "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", 840 | "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", 841 | "engines": { 842 | "node": ">=0.10.0" 843 | } 844 | }, 845 | "node_modules/sort-object": { 846 | "version": "3.0.3", 847 | "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", 848 | "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", 849 | "dependencies": { 850 | "bytewise": "^1.1.0", 851 | "get-value": "^2.0.2", 852 | "is-extendable": "^0.1.1", 853 | "sort-asc": "^0.2.0", 854 | "sort-desc": "^0.2.0", 855 | "union-value": "^1.0.1" 856 | }, 857 | "engines": { 858 | "node": ">=0.10.0" 859 | } 860 | }, 861 | "node_modules/split-string": { 862 | "version": "3.1.0", 863 | "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", 864 | "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", 865 | "dependencies": { 866 | "extend-shallow": "^3.0.0" 867 | }, 868 | "engines": { 869 | "node": ">=0.10.0" 870 | } 871 | }, 872 | "node_modules/split-string/node_modules/extend-shallow": { 873 | "version": "3.0.2", 874 | "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", 875 | "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", 876 | "dependencies": { 877 | "assign-symbols": "^1.0.0", 878 | "is-extendable": "^1.0.1" 879 | }, 880 | "engines": { 881 | "node": ">=0.10.0" 882 | } 883 | }, 884 | "node_modules/split-string/node_modules/is-extendable": { 885 | "version": "1.0.1", 886 | "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", 887 | "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", 888 | "dependencies": { 889 | "is-plain-object": "^2.0.4" 890 | }, 891 | "engines": { 892 | "node": ">=0.10.0" 893 | } 894 | }, 895 | "node_modules/supercluster": { 896 | "version": "8.0.1", 897 | "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", 898 | "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", 899 | "dependencies": { 900 | "kdbush": "^4.0.2" 901 | } 902 | }, 903 | "node_modules/tinyqueue": { 904 | "version": "3.0.0", 905 | "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", 906 | "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" 907 | }, 908 | "node_modules/typewise": { 909 | "version": "1.0.3", 910 | "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", 911 | "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", 912 | "dependencies": { 913 | "typewise-core": "^1.2.0" 914 | } 915 | }, 916 | "node_modules/typewise-core": { 917 | "version": "1.2.0", 918 | "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", 919 | "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==" 920 | }, 921 | "node_modules/union-value": { 922 | "version": "1.0.1", 923 | "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", 924 | "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", 925 | "dependencies": { 926 | "arr-union": "^3.1.0", 927 | "get-value": "^2.0.6", 928 | "is-extendable": "^0.1.1", 929 | "set-value": "^2.0.1" 930 | }, 931 | "engines": { 932 | "node": ">=0.10.0" 933 | } 934 | }, 935 | "node_modules/vt-pbf": { 936 | "version": "3.1.3", 937 | "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", 938 | "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", 939 | "dependencies": { 940 | "@mapbox/point-geometry": "0.1.0", 941 | "@mapbox/vector-tile": "^1.3.1", 942 | "pbf": "^3.2.1" 943 | } 944 | }, 945 | "node_modules/which": { 946 | "version": "4.0.0", 947 | "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", 948 | "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", 949 | "dependencies": { 950 | "isexe": "^3.1.1" 951 | }, 952 | "bin": { 953 | "node-which": "bin/which.js" 954 | }, 955 | "engines": { 956 | "node": "^16.13.0 || >=18.0.0" 957 | } 958 | } 959 | } 960 | } 961 | --------------------------------------------------------------------------------