mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #926 from bilawalsidhu/feat/google-3d-access-tokens
feat: load Google 3D with short-lived server tokens
This commit is contained in:
6 files changed
+266
-12
No files matched your search
+3
-1
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
|
|||||||
export async function createApplicationScene({
|
export async function createApplicationScene({
|
||||||
requestServices,
|
requestServices,
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
|
googleTokens = null,
|
||||||
cesiumToken,
|
cesiumToken,
|
||||||
credits,
|
credits,
|
||||||
MapController = MapStackController,
|
MapController = MapStackController,
|
||||||
@@ -59,11 +60,12 @@ export async function createApplicationScene({
|
|||||||
registerDataCredits(viewer, credits);
|
registerDataCredits(viewer, credits);
|
||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
loaderStatus.textContent =
|
loaderStatus.textContent =
|
||||||
googleApiKey || cesiumToken
|
googleApiKey || googleTokens || cesiumToken
|
||||||
? 'Loading Google 3D Tiles...'
|
? 'Loading Google 3D Tiles...'
|
||||||
: 'Loading the keyless globe...';
|
: 'Loading the keyless globe...';
|
||||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
|
googleTokens,
|
||||||
cesiumToken,
|
cesiumToken,
|
||||||
});
|
});
|
||||||
const tileset = photoreal.tileset;
|
const tileset = photoreal.tileset;
|
||||||
|
|||||||
+77
-1
@@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) {
|
|||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
Ion: { defaultAccessToken: undefined },
|
Ion: { defaultAccessToken: undefined },
|
||||||
GoogleMaps: { defaultApiKey: undefined },
|
GoogleMaps: {
|
||||||
|
defaultApiKey: undefined,
|
||||||
|
mapTilesApiEndpoint: 'https://tile.googleapis.com/',
|
||||||
|
getDefaultCredit: () => undefined,
|
||||||
|
},
|
||||||
|
Resource: class {
|
||||||
|
constructor(options) {
|
||||||
|
Object.assign(this, options);
|
||||||
|
}
|
||||||
|
},
|
||||||
async createGooglePhotorealistic3DTileset(options) {
|
async createGooglePhotorealistic3DTileset(options) {
|
||||||
calls.push({ options, googleKey: options.key });
|
calls.push({ options, googleKey: options.key });
|
||||||
return next();
|
return next();
|
||||||
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
|
|||||||
async fromUrl(resource, options) {
|
async fromUrl(resource, options) {
|
||||||
calls.push({
|
calls.push({
|
||||||
options,
|
options,
|
||||||
|
resource,
|
||||||
ionToken: resource.accessToken,
|
ionToken: resource.accessToken,
|
||||||
assetId: resource.assetId,
|
assetId: resource.assetId,
|
||||||
});
|
});
|
||||||
@@ -140,3 +150,69 @@ test('independent source configurations never mutate shared SDK credentials', as
|
|||||||
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
|
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
|
||||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
|
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function tokenSource(...issued) {
|
||||||
|
const requests = [];
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
async token(options = {}) {
|
||||||
|
requests.push(options);
|
||||||
|
return issued.shift() ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('without a key, server tokens load Google 3D directly', async () => {
|
||||||
|
const tileset = { id: 'token' };
|
||||||
|
const Cesium = fakeCesium([tileset]);
|
||||||
|
const result = await loadPhotorealisticTileset(Cesium, {
|
||||||
|
googleTokens: tokenSource('short-lived'),
|
||||||
|
cesiumToken: 'ion-secret',
|
||||||
|
});
|
||||||
|
assert.equal(result.tileset, tileset);
|
||||||
|
assert.equal(result.route, 'google-token');
|
||||||
|
const { resource, options } = Cesium.calls[0];
|
||||||
|
assert.equal(
|
||||||
|
resource.url,
|
||||||
|
'https://tile.googleapis.com/v1/3dtiles/root.json',
|
||||||
|
);
|
||||||
|
assert.equal(resource.headers.Authorization, 'Bearer short-lived');
|
||||||
|
assert.equal(resource.queryParameters, undefined);
|
||||||
|
assert.equal(options.enableCollision, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tile refused for an expired token retries once with a renewed one', async () => {
|
||||||
|
const Cesium = fakeCesium([{ id: 'token' }]);
|
||||||
|
const tokens = tokenSource('first', 'second');
|
||||||
|
await loadPhotorealisticTileset(Cesium, { googleTokens: tokens });
|
||||||
|
const { resource } = Cesium.calls[0];
|
||||||
|
const tile = { headers: { ...resource.headers } };
|
||||||
|
assert.equal(await resource.retryCallback(tile, { statusCode: 401 }), true);
|
||||||
|
assert.equal(tile.headers.Authorization, 'Bearer second');
|
||||||
|
assert.deepEqual(tokens.requests.at(-1), { replacing: 'first' });
|
||||||
|
assert.equal(resource.retryAttempts, 1);
|
||||||
|
assert.equal(await resource.retryCallback(tile, { statusCode: 500 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a key takes precedence over server tokens', async () => {
|
||||||
|
const Cesium = fakeCesium([{ id: 'direct' }]);
|
||||||
|
const tokens = tokenSource('short-lived');
|
||||||
|
const result = await loadPhotorealisticTileset(Cesium, {
|
||||||
|
googleApiKey: 'google-secret',
|
||||||
|
googleTokens: tokens,
|
||||||
|
});
|
||||||
|
assert.equal(result.route, 'google-direct');
|
||||||
|
assert.equal(tokens.requests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a server without tokens falls back to ion', async () => {
|
||||||
|
const tileset = { id: 'ion' };
|
||||||
|
const Cesium = fakeCesium([tileset]);
|
||||||
|
const result = await loadPhotorealisticTileset(Cesium, {
|
||||||
|
googleTokens: tokenSource(),
|
||||||
|
cesiumToken: 'ion-secret',
|
||||||
|
});
|
||||||
|
assert.equal(result.route, 'google-ion');
|
||||||
|
assert.equal(result.errors.length, 1);
|
||||||
|
assert.equal(Cesium.calls.length, 1);
|
||||||
|
});
|
||||||
+56
-10
@@ -16,30 +16,43 @@ export function selectMapStartupRoute({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
* configured, otherwise through Cesium ion's hosted Google asset. Direct
|
||||||
* direct request fails and an ion token is available, ion is the recovery path.
|
* access uses a browser key, or else short-lived tokens from the app's
|
||||||
|
* server (see googleTokens.js). If direct access fails and an ion token is
|
||||||
|
* available, ion is the recovery path.
|
||||||
*
|
*
|
||||||
* @param {object} Cesium
|
* @param {object} Cesium
|
||||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
* @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
|
||||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
|
||||||
*/
|
*/
|
||||||
export async function loadPhotorealisticTileset(
|
export async function loadPhotorealisticTileset(
|
||||||
Cesium,
|
Cesium,
|
||||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
{ googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
|
||||||
) {
|
) {
|
||||||
const googleKey = clean(googleApiKey);
|
const googleKey = clean(googleApiKey);
|
||||||
const ionToken = clean(cesiumToken);
|
const ionToken = clean(cesiumToken);
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
const attempts = [];
|
const attempts = [];
|
||||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
if (googleKey)
|
||||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
attempts.push({
|
||||||
|
route: 'google-direct',
|
||||||
|
create: () => createGoogleDirectTileset(Cesium, googleKey),
|
||||||
|
});
|
||||||
|
else if (googleTokens)
|
||||||
|
attempts.push({
|
||||||
|
route: 'google-token',
|
||||||
|
create: () => createGoogleTokenTileset(Cesium, googleTokens),
|
||||||
|
});
|
||||||
|
if (ionToken)
|
||||||
|
attempts.push({
|
||||||
|
route: 'google-ion',
|
||||||
|
create: () => createGoogleIonTileset(Cesium, ionToken),
|
||||||
|
});
|
||||||
|
|
||||||
for (const attempt of attempts) {
|
for (const attempt of attempts) {
|
||||||
try {
|
try {
|
||||||
const tileset = attempt.googleKey
|
const tileset = await attempt.create();
|
||||||
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
|
|
||||||
: await createGoogleIonTileset(Cesium, ionToken);
|
|
||||||
return { tileset, route: attempt.route, errors };
|
return { tileset, route: attempt.route, errors };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||||
@@ -60,6 +73,39 @@ export function createGoogleDirectTileset(Cesium, key) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 3D with short-lived tokens instead of a key. Every tile request
|
||||||
|
* carries the token as a header; a tile refused for an expired token gets
|
||||||
|
* the renewed one and is tried once more.
|
||||||
|
*/
|
||||||
|
export async function createGoogleTokenTileset(Cesium, tokens) {
|
||||||
|
const token = await tokens.token();
|
||||||
|
if (!token) throw new Error('Google 3D tokens are not offered');
|
||||||
|
const credit = Cesium.GoogleMaps.getDefaultCredit?.();
|
||||||
|
const resource = new Cesium.Resource({
|
||||||
|
url: `${Cesium.GoogleMaps.mapTilesApiEndpoint}v1/3dtiles/root.json`,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
credits: credit ? [credit] : undefined,
|
||||||
|
retryAttempts: 1,
|
||||||
|
async retryCallback(failed, error) {
|
||||||
|
if (![401, 403].includes(error?.statusCode)) return false;
|
||||||
|
const used = failed.headers.Authorization?.replace(/^Bearer /, '');
|
||||||
|
const renewed = await tokens.token({ replacing: used });
|
||||||
|
if (!renewed || renewed === used) return false;
|
||||||
|
failed.headers.Authorization = `Bearer ${renewed}`;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The settings Cesium's Google helper applies to the key route.
|
||||||
|
return Cesium.Cesium3DTileset.fromUrl(resource, {
|
||||||
|
cacheBytes: 1536 * 1024 * 1024,
|
||||||
|
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
|
||||||
|
enableCollision: true,
|
||||||
|
// Tiles keep drawing their own texture while draped weather loads.
|
||||||
|
asynchronouslyLoadImagery: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createGoogleIonTileset(
|
export async function createGoogleIonTileset(
|
||||||
Cesium,
|
Cesium,
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Short-lived Google access tokens the app's server may offer in place of a
|
||||||
|
* browser key. A server that offers them answers GET GOOGLE_TOKEN_PATH with
|
||||||
|
* `{ accessToken, expiresAt }` (expiresAt in epoch milliseconds); any other
|
||||||
|
* answer means it does not, and the app carries on without them.
|
||||||
|
*/
|
||||||
|
export const GOOGLE_TOKEN_PATH = '/api/google/tiles-token';
|
||||||
|
|
||||||
|
/** Renew a token this long before it expires. */
|
||||||
|
const RENEW_EARLY_MS = 60_000;
|
||||||
|
|
||||||
|
function parseToken(data, now) {
|
||||||
|
const accessToken = data?.accessToken;
|
||||||
|
const expiresAt = Number(data?.expiresAt);
|
||||||
|
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
|
||||||
|
if (!Number.isFinite(expiresAt) || expiresAt <= now) return null;
|
||||||
|
return { accessToken, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A source of tokens from the app's server. `token()` resolves the current
|
||||||
|
* token, renewing it shortly before it expires; `token({ replacing })`
|
||||||
|
* renews only if `replacing` is still the current token, so many tile
|
||||||
|
* requests rejected for the same expired token renew it once. Resolves null
|
||||||
|
* when the server does not offer tokens.
|
||||||
|
*/
|
||||||
|
export function createGoogleTokenSource({
|
||||||
|
fetchImpl = (...args) => fetch(...args),
|
||||||
|
url = GOOGLE_TOKEN_PATH,
|
||||||
|
now = () => Date.now(),
|
||||||
|
} = {}) {
|
||||||
|
let current = null;
|
||||||
|
let pending = null;
|
||||||
|
|
||||||
|
async function request() {
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return parseToken(await response.json(), now());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async token({ replacing } = {}) {
|
||||||
|
const usable =
|
||||||
|
current &&
|
||||||
|
current.expiresAt - RENEW_EARLY_MS > now() &&
|
||||||
|
(replacing === undefined || current.accessToken !== replacing);
|
||||||
|
if (usable) return current.accessToken;
|
||||||
|
pending ??= request().finally(() => {
|
||||||
|
pending = null;
|
||||||
|
});
|
||||||
|
current = await pending;
|
||||||
|
return current?.accessToken ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createGoogleTokenSource, GOOGLE_TOKEN_PATH } from './googleTokens.js';
|
||||||
|
|
||||||
|
function server(answers) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
const answer = answers.shift();
|
||||||
|
if (answer instanceof Error) throw answer;
|
||||||
|
return answer;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = (accessToken, expiresAt) =>
|
||||||
|
Response.json({ accessToken, expiresAt });
|
||||||
|
|
||||||
|
test('a token is reused until shortly before it expires', async () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const { calls, fetchImpl } = server([
|
||||||
|
offer('first', now + 900_000),
|
||||||
|
offer('second', now + 1_800_000),
|
||||||
|
]);
|
||||||
|
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
|
||||||
|
assert.equal(await tokens.token(), 'first');
|
||||||
|
assert.equal(await tokens.token(), 'first');
|
||||||
|
assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]);
|
||||||
|
now += 850_000;
|
||||||
|
assert.equal(await tokens.token(), 'second');
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests refused for the same token renew it once', async () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
const { calls, fetchImpl } = server([
|
||||||
|
offer('first', now + 900_000),
|
||||||
|
offer('second', now + 900_000),
|
||||||
|
]);
|
||||||
|
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
|
||||||
|
await tokens.token();
|
||||||
|
const renewed = await Promise.all([
|
||||||
|
tokens.token({ replacing: 'first' }),
|
||||||
|
tokens.token({ replacing: 'first' }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(renewed, ['second', 'second']);
|
||||||
|
assert.equal(await tokens.token({ replacing: 'first' }), 'second');
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a server that does not offer tokens yields none', async () => {
|
||||||
|
for (const answer of [
|
||||||
|
new Response('Not found', { status: 404 }),
|
||||||
|
new Response('<!doctype html>', { status: 200 }),
|
||||||
|
Response.json({ accessToken: '', expiresAt: Date.now() + 60_000 }),
|
||||||
|
Response.json({ accessToken: 'stale', expiresAt: 1 }),
|
||||||
|
new TypeError('offline'),
|
||||||
|
]) {
|
||||||
|
const { fetchImpl } = server([answer]);
|
||||||
|
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { createStandaloneScene } from './scene.js';
|
|||||||
import { createStandaloneControls } from './controls.js';
|
import { createStandaloneControls } from './controls.js';
|
||||||
import { createStandaloneData } from './data.js';
|
import { createStandaloneData } from './data.js';
|
||||||
import { createStandaloneTools } from './tools.js';
|
import { createStandaloneTools } from './tools.js';
|
||||||
|
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||||
|
|
||||||
// The existing controls and layer catalog contain page-scoped state.
|
// The existing controls and layer catalog contain page-scoped state.
|
||||||
let constructed = false;
|
let constructed = false;
|
||||||
@@ -39,6 +40,9 @@ export function createStandaloneApplication({
|
|||||||
const scene = await createStandaloneScene({
|
const scene = await createStandaloneScene({
|
||||||
...context,
|
...context,
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
|
// Without a key, Google 3D can still load with tokens from the
|
||||||
|
// app's server when it offers them.
|
||||||
|
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
|
||||||
cesiumToken,
|
cesiumToken,
|
||||||
loaderStatus,
|
loaderStatus,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user