diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..6c59600a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through + their ion-backed providers, retaining ion and per-tile attribution without + widening the Content Security Policy. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..f68d8a194 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5018,6 +5018,12 @@ photoreal globe is hidden. Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. +Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied +token, then construct the Bing provider with `tileProtocol: 'https'`. Both styles +request HTTPS tiles even on HTTP localhost, with the existing Content Security +Policy unchanged. Ion attribution and Bing's per-tile credits are preserved; +other ion imagery assets continue through the SDK's standard ion factory. + The controller accepts other source registries without adding provider branches. Each instance caches source construction, ignores superseded scene changes and releases imagery layers/listeners on replacement. Destroy invalidates pending diff --git a/src/maps/imagery.js b/src/maps/imagery.js index 32fd3f375..d151b6ce6 100644 --- a/src/maps/imagery.js +++ b/src/maps/imagery.js @@ -25,5 +25,42 @@ export function createEsriImagery() { export function createIonImagery(style, accessToken) { accessToken = String(accessToken || '').trim(); if (!accessToken) throw new Error('Ion imagery requires an explicit token'); + if ( + style === Cesium.IonWorldImageryStyle.AERIAL || + style === Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS + ) { + return createBingIonImagery(style, accessToken); + } return Cesium.IonImageryProvider.fromAssetId(style, { accessToken }); } + +async function createBingIonImagery(style, accessToken) { + const endpoint = await new Cesium.Resource({ + url: `https://api.cesium.com/v1/assets/${style}/endpoint`, + queryParameters: { access_token: accessToken }, + }).fetchJson(); + if ( + endpoint?.type !== 'IMAGERY' || + endpoint.externalType !== 'BING' || + typeof endpoint.options?.url !== 'string' + ) { + throw new Error('Ion world imagery did not return a Bing imagery endpoint'); + } + // Bing otherwise follows the page's HTTP scheme on localhost. Resolve its + // ion-issued credentials through public APIs so tiles use HTTPS without + // weakening CSP, changing SDK defaults or touching provider internals. + const { url, ...options } = endpoint.options; + const provider = await Cesium.BingMapsImageryProvider.fromUrl(url, { + ...options, + tileProtocol: 'https', + }); + const ionCredits = (endpoint.attributions ?? []).map( + ({ html, collapsible }) => new Cesium.Credit(html, collapsible === false), + ); + const getTileCredits = provider.getTileCredits.bind(provider); + provider.getTileCredits = (x, y, level) => [ + ...ionCredits, + ...(getTileCredits(x, y, level) ?? []), + ]; + return provider; +} diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index 15571666d..d3aaa5068 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -89,7 +89,7 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul calls.push({ kind: 'terrain', resource, options }); return { id: 'terrain' }; }; - await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token'); + await createIonImagery(12345, 'imagery-token'); const result = await createWorldTerrain('terrain-token'); assert.equal(calls[0].options.accessToken, 'imagery-token'); assert.equal(calls[1].options.accessToken, 'terrain-token'); @@ -104,6 +104,162 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul } }); +test('both Bing ion stacks use HTTPS and retain ion and per-tile attribution', async (t) => { + const defaultToken = Cesium.Ion.defaultAccessToken; + const metadataCalls = []; + const bingCalls = []; + const perTileCalls = []; + const bingCredit = new Cesium.Credit('Bing tile credit', true); + const endpoint = { + type: 'IMAGERY', + externalType: 'BING', + options: Object.freeze({ + url: 'https://dev.virtualearth.net', + key: 'ion-issued-bing-fixture', + mapStyle: 'Aerial', + tileProtocol: 'http', + }), + attributions: [ + { html: 'Ion required credit', collapsible: false }, + { html: 'Ion optional credit', collapsible: true }, + ], + }; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async function () { + metadataCalls.push({ + url: this.url, + token: this.queryParameters.access_token, + }); + const mapStyle = this.url.includes( + `/assets/${Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS}/`, + ) + ? 'AerialWithLabels' + : 'Aerial'; + return { + ...endpoint, + options: Object.freeze({ ...endpoint.options, mapStyle }), + }; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing must not inherit the local HTTP page protocol'), + ); + t.mock.method( + Cesium.BingMapsImageryProvider, + 'fromUrl', + async (url, options) => { + bingCalls.push({ url, options }); + const provider = { + errorEvent: new Cesium.Event(), + getTileCredits(x, y, level) { + assert.equal(this, provider); + perTileCalls.push([x, y, level]); + return level === 0 ? undefined : [bingCredit]; + }, + }; + return provider; + }, + ); + for (const style of [ + Cesium.IonWorldImageryStyle.AERIAL, + Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS, + ]) { + const provider = await createIonImagery(style, ' own-ion-token '); + const credits = provider.getTileCredits(3, 4, 5); + assert.deepEqual( + credits.map(({ html, showOnScreen }) => ({ html, showOnScreen })), + [ + { html: 'Ion required credit', showOnScreen: true }, + { html: 'Ion optional credit', showOnScreen: false }, + { html: 'Bing tile credit', showOnScreen: true }, + ], + ); + assert.equal(credits[2], bingCredit); + assert.equal(provider.getTileCredits(0, 0, 0).length, 2); + assert.ok(provider.errorEvent instanceof Cesium.Event); + assert.equal(metadataCalls.at(-1).token, 'own-ion-token'); + const requested = new URL(metadataCalls.at(-1).url); + assert.equal(requested.origin, 'https://api.cesium.com'); + assert.equal(requested.pathname, `/v1/assets/${style}/endpoint`); + } + assert.deepEqual(perTileCalls, [ + [3, 4, 5], + [0, 0, 0], + [3, 4, 5], + [0, 0, 0], + ]); + assert.equal(bingCalls.length, 2); + for (const [index, { url, options }] of bingCalls.entries()) { + assert.equal(url, 'https://dev.virtualearth.net'); + assert.deepEqual(options, { + key: 'ion-issued-bing-fixture', + mapStyle: index === 0 ? 'Aerial' : 'AerialWithLabels', + tileProtocol: 'https', + }); + } + assert.equal(endpoint.options.tileProtocol, 'http'); + assert.equal(Cesium.Ion.defaultAccessToken, defaultToken); +}); + +test('Bing ion metadata and construction failures propagate without another source', async (t) => { + const style = Cesium.IonWorldImageryStyle.AERIAL; + const metadataError = new Error('ion metadata fixture failure'); + const providerError = new Error('Bing construction fixture failure'); + let metadata = () => { + throw metadataError; + }; + let bingCalls = 0; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async () => metadata()); + t.mock.method(Cesium.BingMapsImageryProvider, 'fromUrl', async () => { + bingCalls += 1; + throw providerError; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing failures must not silently use an alternate provider'), + ); + await assert.rejects(createIonImagery(style, 'test-token'), metadataError); + for (const endpoint of [ + undefined, + { + type: 'TERRAIN', + externalType: 'BING', + options: { url: 'https://example.test' }, + }, + { + type: 'IMAGERY', + externalType: 'OTHER', + options: { url: 'https://example.test' }, + }, + { type: 'IMAGERY', externalType: 'BING', options: {} }, + ]) { + metadata = () => endpoint; + await assert.rejects( + createIonImagery(style, 'test-token'), + /Bing imagery endpoint/, + ); + } + assert.equal(bingCalls, 0); + metadata = () => ({ + type: 'IMAGERY', + externalType: 'BING', + options: { url: 'https://dev.virtualearth.net', key: 'bing-fixture' }, + }); + await assert.rejects(createIonImagery(style, 'test-token'), providerError); + assert.equal(bingCalls, 1); +}); + +test('other ion imagery assets keep the standard SDK factory', async (t) => { + const expected = {}; + const calls = []; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', () => + assert.fail('generic assets must use the SDK ion factory'), + ); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', async (...args) => { + calls.push(args); + return expected; + }); + assert.equal(await createIonImagery(12345, ' custom-token '), expected); + assert.deepEqual(calls, [[12345, { accessToken: 'custom-token' }]]); +}); + test('cancellation after ion metadata prevents terrain construction', async () => { const originalResource = Cesium.IonResource.fromAssetId; const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;