From e023c11f26c467c98fbad8fd29417a12b45cc0df Mon Sep 17 00:00:00 2001 From: h4badger <204420093+h4badger@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:07:13 -0700 Subject: [PATCH 1/3] Add topographic map layers (#1891) This adds two optional map tile providers: - OpenTopoMap - USGS They are disabled by default, but can be quite useful for visualizing repeater sites with terrain features visible. (cherry picked from commit 34b41fd5b6df5b685e69482d9b74ff00528641db) Co-Authored-By: Claude Opus 5 --- config.example.json | 8 ++++++++ public/map-tile-providers.js | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/config.example.json b/config.example.json index 327e32d91..e169a6aa5 100644 --- a/config.example.json +++ b/config.example.json @@ -89,10 +89,18 @@ "provider": "", "token": "" }, + "_comment_opentopomap": "OpenTopoMap topographical tile provider. Limited to zoom level 17.", + "opentopomap": { + "enabled": false + }, "_comment_stamen": "Stamen (hosted by Stadia). WARNING: Tokens are sent to the browser. Apply origin/referrer restrictions.", "stamen": { "enabled": false, "token": "" + }, + "_comment_usgs": "USGS topographical tile provider. Limited to zoom level 16.", + "usgs": { + "enabled": false } } } diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index 6266b81f9..fca6d1d0a 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -49,8 +49,11 @@ 'positron-dark': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png'; }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 }, 'osm-standard': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: null, type: 'light', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 }, 'osm-dark': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 }, + 'opentopomap': { provider: 'opentopomap', label: 'OpenTopoMap', url: function() { return 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png'; }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap contributors, SRTM, © OpenTopoMap (CC-BY-SA)', maxZoom: 17 }, 'stamen-toner-lite': { provider: 'stamen', label: 'Stamen Toner Lite', url: _getStamenUrl, invertFilter: null, type: 'light', attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', maxZoom: 20 }, 'stamen-toner-dark': { provider: 'stamen', label: 'Stamen Toner Lite', url: _getStamenUrl, invertFilter: INVERT_CSS, type: 'dark', attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', maxZoom: 20 }, + 'usgs-topo': { provider: 'usgs', label: 'USGS Topographic', url: function() { return 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}'; }, invertFilter: null, type: 'light', attribution: 'U.S. Geological Survey, National Geospatial Program', maxZoom: 16 }, + 'usgs-imagery': { provider: 'usgs', label: 'USGS Imagery', url: function() { return 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryTopo/MapServer/tile/{z}/{y}/{x}'; }, invertFilter: null, type: 'light', attribution: 'U.S. Geological Survey, National Geospatial Program', maxZoom: 16 }, 'esri-darkgray-labels': { provider: 'esri', label: 'Esri Dark Gray Canvas', url: function() { return 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}'; }, invertFilter: null, type: 'dark', attribution: 'Tiles © Esri', maxZoom: 19 } }; @@ -65,6 +68,8 @@ var HAS_CARTO = !_cfg || !_cfg.providers || !_cfg.providers.carto || _cfg.providers.carto.enabled !== false; var HAS_OSM = _cfg && _cfg.providers && _cfg.providers.osm && _cfg.providers.osm.enabled; + var HAS_OPENTOPOMAP = _cfg && _cfg.providers && _cfg.providers.opentopomap && _cfg.providers.opentopomap.enabled; + var HAS_USGS = _cfg && _cfg.providers && _cfg.providers.usgs && _cfg.providers.usgs.enabled; var HAS_STAMEN = _cfg && _cfg.providers && _cfg.providers.stamen && _cfg.providers.stamen.enabled && !!_cfg.providers.stamen.token; var HAS_ESRI = true; // Kept for backwards compatibility @@ -73,7 +78,9 @@ var style = BASE_STYLES[key]; if (style.provider === 'carto' && HAS_CARTO) REGISTRY[key] = style; if (style.provider === 'osm' && HAS_OSM) REGISTRY[key] = style; + if (style.provider === 'opentopomap' && HAS_OPENTOPOMAP) REGISTRY[key] = style; if (style.provider === 'stamen' && HAS_STAMEN) REGISTRY[key] = style; + if (style.provider === 'usgs' && HAS_USGS) REGISTRY[key] = style; if (style.provider === 'esri' && HAS_ESRI) REGISTRY[key] = style; } From fc5b5a8c51ee5bcda60035d30a838010ff9f13da Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Sun, 20 Sep 2026 16:57:30 +0200 Subject: [PATCH 2/3] test(map): pin the opt-in gating, attribution and URL templates for #1891 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port added three third-party tile layers with no test coverage. The default-off gating is the security-relevant part — these layers send the user's IP to opentopomap.org and basemap.nationalmap.gov — so it is now pinned in the CI-registered suite (test-issue-1420-tile-providers.js): - absent with no MC_MAP_CFG, and absent under the shipped config.example.json (proving what we ship stays opt-in) - each provider appears only under its own enabled=true, and enabling one does not pull in the other - OpenTopoMap keeps its CC-BY-SA credit and USGS its agency credit; both keep the zoom caps their services actually enforce - URL templates pin OpenTopoMap's {z}/{x}/{y} against ArcGIS's {z}/{y}/{x}, require TLS, and assert no credentials are appended All four mutations fail: removing the USGS gate (4 failures), dropping the CC-BY-SA credit (1), swapping the USGS coordinate order (1), and raising maxZoom past the service limit (1). Verified against the live services rather than by reading: OpenTopoMap returns 200 image/png worldwide; both USGS endpoints return 200 for US tiles (San Francisco, Denver) and 404 outside the US. That US-only coverage was undocumented, so config.example.json now says so — an operator enabling USGS from Europe would otherwise get a blank map with no explanation. Co-Authored-By: Claude Opus 5 --- config.example.json | 2 +- test-issue-1420-tile-providers.js | 82 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/config.example.json b/config.example.json index e169a6aa5..6ff634f70 100644 --- a/config.example.json +++ b/config.example.json @@ -98,7 +98,7 @@ "enabled": false, "token": "" }, - "_comment_usgs": "USGS topographical tile provider. Limited to zoom level 16.", + "_comment_usgs": "USGS topographical tile providers (USGS Topo, USGS Imagery). Limited to zoom level 16, and the National Map only serves tiles for the United States — outside US coverage these layers render blank.", "usgs": { "enabled": false } diff --git a/test-issue-1420-tile-providers.js b/test-issue-1420-tile-providers.js index c70edb364..f54640b2f 100644 --- a/test-issue-1420-tile-providers.js +++ b/test-issue-1420-tile-providers.js @@ -135,6 +135,88 @@ test('OSM providers appear when osm.enabled=true in MC_MAP_CFG', () => { for (const id of ALL_OSM_IDS) assert.ok(reg[id], 'should have ' + id + ' when osm enabled'); }); +// --- #1891: opt-in topographic layers (OpenTopoMap, USGS) --- +// These reach third-party tile servers the operator has to opt into, so the +// default-off gating is the security-relevant part, not a nicety. +const TOPO_IDS = { opentopomap: ['opentopomap'], usgs: ['usgs-topo', 'usgs-imagery'] }; + +test('#1891 topo providers are absent by default (no MC_MAP_CFG)', () => { + const ctx = makeSandbox(); + loadProviders(ctx); + ctx.window.MC_initTileRegistry(false); + const reg = ctx.window.MC_TILE_PROVIDERS; + for (const ids of Object.values(TOPO_IDS)) { + for (const id of ids) assert.ok(!reg[id], id + ' must not appear without explicit opt-in'); + } +}); + +test('#1891 topo providers stay absent under the shipped config.example.json', () => { + const providers = JSON.parse(require('fs').readFileSync('config.example.json', 'utf8')).map.tiles.providers; + const ctx = makeSandbox(); + loadProviders(ctx); + ctx.window.MC_MAP_CFG = { tiles: { providers } }; + ctx.window.MC_initTileRegistry(false); + const reg = ctx.window.MC_TILE_PROVIDERS; + for (const ids of Object.values(TOPO_IDS)) { + for (const id of ids) assert.ok(!reg[id], id + ' must ship disabled in config.example.json'); + } +}); + +for (const [provider, ids] of Object.entries(TOPO_IDS)) { + test('#1891 ' + provider + ' appears only when ' + provider + '.enabled=true', () => { + const on = makeSandbox(); + loadProviders(on); + on.window.MC_MAP_CFG = { tiles: { providers: { [provider]: { enabled: true } } } }; + on.window.MC_initTileRegistry(false); + for (const id of ids) assert.ok(on.window.MC_TILE_PROVIDERS[id], id + ' should appear when enabled'); + // Enabling one topo provider must not pull in the other. + for (const [other, otherIds] of Object.entries(TOPO_IDS)) { + if (other === provider) continue; + for (const id of otherIds) assert.ok(!on.window.MC_TILE_PROVIDERS[id], id + ' must stay absent'); + } + + const off = makeSandbox(); + loadProviders(off); + off.window.MC_MAP_CFG = { tiles: { providers: { [provider]: { enabled: false } } } }; + off.window.MC_initTileRegistry(false); + for (const id of ids) assert.ok(!off.window.MC_TILE_PROVIDERS[id], id + ' should be absent when disabled'); + }); +} + +test('#1891 topo layers carry their required attribution and zoom caps', () => { + const ctx = makeSandbox(); + loadProviders(ctx); + ctx.window.MC_MAP_CFG = { tiles: { providers: { opentopomap: { enabled: true }, usgs: { enabled: true } } } }; + ctx.window.MC_initTileRegistry(false); + const reg = ctx.window.MC_TILE_PROVIDERS; + // OpenTopoMap is CC-BY-SA: dropping the credit is a licence violation. + assert.match(reg['opentopomap'].attribution, /OpenTopoMap/, 'OpenTopoMap credit required'); + assert.match(reg['opentopomap'].attribution, /CC-BY-SA/, 'OpenTopoMap licence required'); + assert.strictEqual(reg['opentopomap'].maxZoom, 17, 'OpenTopoMap serves no tiles above z17'); + for (const id of TOPO_IDS.usgs) { + assert.match(reg[id].attribution, /U\.S\. Geological Survey/, id + ' must credit USGS'); + assert.strictEqual(reg[id].maxZoom, 16, id + ' serves no tiles above z16'); + } +}); + +test('#1891 topo tile URLs use the template each service expects', () => { + const ctx = makeSandbox(); + loadProviders(ctx); + ctx.window.MC_MAP_CFG = { tiles: { providers: { opentopomap: { enabled: true }, usgs: { enabled: true } } } }; + ctx.window.MC_initTileRegistry(false); + const reg = ctx.window.MC_TILE_PROVIDERS; + const urlOf = (id) => typeof reg[id].url === 'function' ? reg[id].url() : reg[id].url; + // OpenTopoMap is {z}/{x}/{y}; the ArcGIS services are {z}/{y}/{x}. Getting + // the order wrong still yields 200s in some places, so pin it here. + assert.ok(urlOf('opentopomap').endsWith('/{z}/{x}/{y}.png'), 'OpenTopoMap uses z/x/y'); + assert.ok(urlOf('opentopomap').startsWith('https://'), 'tiles must be fetched over TLS'); + for (const id of TOPO_IDS.usgs) { + assert.ok(urlOf(id).endsWith('/tile/{z}/{y}/{x}'), id + ' uses the ArcGIS z/y/x order'); + assert.ok(urlOf(id).startsWith('https://'), id + ' must be fetched over TLS'); + assert.ok(!/\?|api_key|token/i.test(urlOf(id)), id + ' needs no credentials'); + } +}); + test('OSM providers absent when osm.enabled=false', () => { const ctx = makeSandbox(); loadProviders(ctx); From acc3c23534aa665f0724ae8e44e265ce5b71ea17 Mon Sep 17 00:00:00 2001 From: Dennis Jakobsen Date: Sun, 20 Sep 2026 17:14:11 +0200 Subject: [PATCH 3/3] reviewfix(map): link the CC-BY-SA licence, warn operators about third-party exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review confirmed the port is privacy-clean — including the case I was most concerned about: after an operator disables a provider, a stale localStorage id does NOT leak requests, because _hasId tests the rebuilt REGISTRY. Verified end to end in a browser: 0 requests to the disabled host. Secrets clean (both URLs are constant literals, no config interpolation), and Leaflet disables the radios for capped layers rather than requesting tiles that do not exist. Two licence findings addressed here: - The OpenTopoMap attribution was the canonical leaflet-providers string with every hyperlink stripped. CC BY-SA 3.0 4(a) asks for the licence URI where the medium makes it reasonable, and Leaflet renders HTML in , so the credit and the licence are now links. The test pins both URIs, not just the words. - Neither config comment said that enabling a layer makes USERS' BROWSERS contact a third party, exposing their IP and approximate viewing area. The existing osm/stamen warnings only cover the operator's own token leaking. Both comments now say it, and the OpenTopoMap one states the CC-BY-SA duty and that it is a small volunteer-run service. Also fixed the cwd-relative config read in my previous test commit to use path.join(__dirname, …) like the rest of that file. NOT fixed here, filed as follow-up: map.js:342 and live.js:1454 mutate tileLayer.options.attribution after the layer was added, which Leaflet ignores, so a live provider swap or a theme toggle leaves the previous provider's credit on screen — OpenTopoMap tiles under a CartoDB credit with no CC-BY-SA notice. It is pre-existing (osm-standard already trips it) and in files this port does not touch, and the layers ship default-off so nothing is live, but it must be fixed before anyone enables OpenTopoMap. Co-Authored-By: Claude Opus 5 --- config.example.json | 4 ++-- public/map-tile-providers.js | 2 +- test-issue-1420-tile-providers.js | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/config.example.json b/config.example.json index 6ff634f70..7e2aa3ae7 100644 --- a/config.example.json +++ b/config.example.json @@ -89,7 +89,7 @@ "provider": "", "token": "" }, - "_comment_opentopomap": "OpenTopoMap topographical tile provider. Limited to zoom level 17.", + "_comment_opentopomap": "OpenTopoMap topographical tile provider. Enabling this makes USERS' BROWSERS fetch tiles from tile.opentopomap.org, exposing their IP and approximate viewing area to that third party. Tiles are CC-BY-SA: the credit and licence link must stay on the map. Limited to zoom level 17, and it is a small volunteer-run service with informal fair-use expectations.", "opentopomap": { "enabled": false }, @@ -98,7 +98,7 @@ "enabled": false, "token": "" }, - "_comment_usgs": "USGS topographical tile providers (USGS Topo, USGS Imagery). Limited to zoom level 16, and the National Map only serves tiles for the United States — outside US coverage these layers render blank.", + "_comment_usgs": "USGS topographical tile providers (USGS Topo, USGS Imagery). Enabling these makes USERS' BROWSERS fetch tiles from basemap.nationalmap.gov, exposing their IP and approximate viewing area to that third party. Limited to zoom level 16, and The National Map only serves the United States and its territories — elsewhere these layers render blank with no on-screen explanation.", "usgs": { "enabled": false } diff --git a/public/map-tile-providers.js b/public/map-tile-providers.js index fca6d1d0a..7926f80f9 100644 --- a/public/map-tile-providers.js +++ b/public/map-tile-providers.js @@ -49,7 +49,7 @@ 'positron-dark': { provider: 'carto', label: 'Carto Positron', url: function() { return _getCartoBase() + '/light_all/{z}/{x}/{y}{r}.png'; }, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap © CartoDB', maxZoom: 19 }, 'osm-standard': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: null, type: 'light', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 }, 'osm-dark': { provider: 'osm', label: 'OSM Standard', url: _getOsmUrl, invertFilter: INVERT_CSS, type: 'dark', attribution: '© OpenStreetMap contributors, Maps © Mapbox/Thunderforest/MapTiler', maxZoom: 18 }, - 'opentopomap': { provider: 'opentopomap', label: 'OpenTopoMap', url: function() { return 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png'; }, invertFilter: null, type: 'light', attribution: '© OpenStreetMap contributors, SRTM, © OpenTopoMap (CC-BY-SA)', maxZoom: 17 }, + 'opentopomap': { provider: 'opentopomap', label: 'OpenTopoMap', url: function() { return 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png'; }, invertFilter: null, type: 'light', attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)', maxZoom: 17 }, 'stamen-toner-lite': { provider: 'stamen', label: 'Stamen Toner Lite', url: _getStamenUrl, invertFilter: null, type: 'light', attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', maxZoom: 20 }, 'stamen-toner-dark': { provider: 'stamen', label: 'Stamen Toner Lite', url: _getStamenUrl, invertFilter: INVERT_CSS, type: 'dark', attribution: '© Stadia Maps © Stamen Design © OpenStreetMap', maxZoom: 20 }, 'usgs-topo': { provider: 'usgs', label: 'USGS Topographic', url: function() { return 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}'; }, invertFilter: null, type: 'light', attribution: 'U.S. Geological Survey, National Geospatial Program', maxZoom: 16 }, diff --git a/test-issue-1420-tile-providers.js b/test-issue-1420-tile-providers.js index f54640b2f..4c28e7de6 100644 --- a/test-issue-1420-tile-providers.js +++ b/test-issue-1420-tile-providers.js @@ -151,7 +151,7 @@ test('#1891 topo providers are absent by default (no MC_MAP_CFG)', () => { }); test('#1891 topo providers stay absent under the shipped config.example.json', () => { - const providers = JSON.parse(require('fs').readFileSync('config.example.json', 'utf8')).map.tiles.providers; + const providers = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8')).map.tiles.providers; const ctx = makeSandbox(); loadProviders(ctx); ctx.window.MC_MAP_CFG = { tiles: { providers } }; @@ -192,6 +192,10 @@ test('#1891 topo layers carry their required attribution and zoom caps', () => { // OpenTopoMap is CC-BY-SA: dropping the credit is a licence violation. assert.match(reg['opentopomap'].attribution, /OpenTopoMap/, 'OpenTopoMap credit required'); assert.match(reg['opentopomap'].attribution, /CC-BY-SA/, 'OpenTopoMap licence required'); + // CC BY-SA 3.0 4(a) asks for the licence URI where the medium allows it, + // and Leaflet renders HTML here, so the credit and licence must be links. + assert.match(reg['opentopomap'].attribution, /creativecommons\.org\/licenses\/by-sa/, 'CC-BY-SA licence URI required'); + assert.match(reg['opentopomap'].attribution, /href="https:\/\/opentopomap\.org"/, 'OpenTopoMap credit must link to the project'); assert.strictEqual(reg['opentopomap'].maxZoom, 17, 'OpenTopoMap serves no tiles above z17'); for (const id of TOPO_IDS.usgs) { assert.match(reg[id].attribution, /U\.S\. Geological Survey/, id + ' must credit USGS');