mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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({
|
||||
requestServices,
|
||||
googleApiKey,
|
||||
googleTokens = null,
|
||||
cesiumToken,
|
||||
credits,
|
||||
MapController = MapStackController,
|
||||
@@ -59,11 +60,12 @@ export async function createApplicationScene({
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || cesiumToken
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
googleTokens,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
|
||||
+77
-1
@@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) {
|
||||
return {
|
||||
calls,
|
||||
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) {
|
||||
calls.push({ options, googleKey: options.key });
|
||||
return next();
|
||||
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
|
||||
async fromUrl(resource, options) {
|
||||
calls.push({
|
||||
options,
|
||||
resource,
|
||||
ionToken: resource.accessToken,
|
||||
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.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
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. Direct
|
||||
* 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 {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
{ googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
if (googleKey)
|
||||
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) {
|
||||
try {
|
||||
const tileset = attempt.googleKey
|
||||
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
|
||||
: await createGoogleIonTileset(Cesium, ionToken);
|
||||
const tileset = await attempt.create();
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (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(
|
||||
Cesium,
|
||||
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 { createStandaloneData } from './data.js';
|
||||
import { createStandaloneTools } from './tools.js';
|
||||
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||
|
||||
// The existing controls and layer catalog contain page-scoped state.
|
||||
let constructed = false;
|
||||
@@ -39,6 +40,9 @@ export function createStandaloneApplication({
|
||||
const scene = await createStandaloneScene({
|
||||
...context,
|
||||
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,
|
||||
loaderStatus,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user