diff --git a/config.example.json b/config.example.json
index 327e32d91..7e2aa3ae7 100644
--- a/config.example.json
+++ b/config.example.json
@@ -89,10 +89,18 @@
"provider": "",
"token": ""
},
+ "_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
+ },
"_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 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 6266b81f9..7926f80f9 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: '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 },
+ '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;
}
diff --git a/test-issue-1420-tile-providers.js b/test-issue-1420-tile-providers.js
index c70edb364..4c28e7de6 100644
--- a/test-issue-1420-tile-providers.js
+++ b/test-issue-1420-tile-providers.js
@@ -135,6 +135,92 @@ 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(fs.readFileSync(path.join(__dirname, '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');
+ // 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');
+ 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);