mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(street-level): pause Street View details retries after a failure
Maintainer follow-up to #966 review: - A failed panorama-details lookup now waits DETAILS_RETRY_COOLDOWN_MS (5 s) per panorama before a view event can retry it, so dragging the view after a failure no longer issues a lookup on every pov change. A success clears the pause; one panorama's failure does not delay another. - SECURITY.md notes that allowing maps.googleapis.com in script-src also trusts its JSONP endpoints, an accepted limitation alongside the existing 'unsafe-eval' allowance. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ef396655fc
commit
84f634efb0
5 files changed
+65
-6
No files matched your search
@@ -75,6 +75,8 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
|
||||
|
||||
@@ -3108,7 +3108,8 @@ disabled until the camera is at street zoom. The viewer is Google's `StreetViewP
|
||||
element in the viewer host, without Google's fullscreen, close and address
|
||||
controls (the address goes in the caption); a pose goes out at once and
|
||||
again when the panorama's month, photographer and address arrive (one
|
||||
lookup per panorama per session, seeded by the nearest lookup). A key
|
||||
successful lookup per panorama per session, seeded by the nearest lookup;
|
||||
failed details lookups wait five seconds before a view event can retry). A key
|
||||
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
|
||||
not allow, billing off) reads KEY REJECTED and fails a pending open at
|
||||
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
|
||||
|
||||
@@ -31,6 +31,8 @@ export const KEY_REJECTED_MESSAGE =
|
||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
||||
/** A panorama that has not shown by then has failed to open. */
|
||||
export const OPEN_TIMEOUT_MS = 20_000;
|
||||
/** Minimum pause after a failed panorama-details lookup before a view can retry. */
|
||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||
|
||||
/** Google answered nothing: the network, or a key Google ignores. */
|
||||
export const NO_ANSWER_MESSAGE =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
DETAILS_RETRY_COOLDOWN_MS,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
NO_ANSWER_MESSAGE,
|
||||
@@ -50,6 +51,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
const described = new Map();
|
||||
/** Panorama ids whose lookup is in flight. */
|
||||
const lookingUp = new Set();
|
||||
/** Panorama id → earliest retry time after a failed details lookup. */
|
||||
const retryAfter = new Map();
|
||||
const listeners = new Set();
|
||||
|
||||
function requestRender() {
|
||||
@@ -81,11 +84,12 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
|
||||
/**
|
||||
* Look up a panorama's date, photographer and address once; the pose
|
||||
* already out is sent again with them. A failed lookup is asked again on
|
||||
* the next event.
|
||||
* already out is sent again with them. After a failure, view events can
|
||||
* retry once the cooldown expires.
|
||||
*/
|
||||
async function describe(panoId) {
|
||||
if (described.has(panoId) || lookingUp.has(panoId)) return;
|
||||
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
|
||||
lookingUp.add(panoId);
|
||||
try {
|
||||
service ||= getService
|
||||
@@ -93,7 +97,9 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
: new (await ensureLibrary()).StreetViewService();
|
||||
const { data } = await service.getPanorama({ pano: panoId });
|
||||
described.set(panoId, metaFrom(data));
|
||||
retryAfter.delete(panoId);
|
||||
} catch {
|
||||
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
|
||||
return;
|
||||
} finally {
|
||||
lookingUp.delete(panoId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import { DETAILS_RETRY_COOLDOWN_MS } from './policy.js';
|
||||
import {
|
||||
fakeHost,
|
||||
fakeMapsLoader,
|
||||
@@ -265,19 +266,66 @@ test('a panorama that never shows fails after the timeout and leaves no listener
|
||||
assert.equal(loader.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('a date lookup that failed is asked again on the next view change', async () => {
|
||||
test('failed details lookups cool down through repeated drags and retry after the pause', async (t) => {
|
||||
let now = 1_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
let failures = 1;
|
||||
let failures = 2;
|
||||
let calls = 0;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
calls++;
|
||||
if (failures-- > 0) return Promise.reject(new Error('offline'));
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
|
||||
assert.equal(calls, 1);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
for (let heading = 0; heading < 20; heading++) {
|
||||
fake.built[0].setPov({ heading, pitch: 0 });
|
||||
await settle();
|
||||
}
|
||||
assert.equal(calls, attempt + 1, 'drags do not retry during the pause');
|
||||
assert.equal(poses.at(-1).bearing, 19, 'pose updates still follow drags');
|
||||
now += DETAILS_RETRY_COOLDOWN_MS - 1;
|
||||
fake.built[0].setPov({ heading: 20, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, attempt + 1, 'still paused just before the deadline');
|
||||
now++;
|
||||
fake.built[0].setPov({ heading: 21, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, attempt + 2, 'one retry at the deadline');
|
||||
}
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
|
||||
now += DETAILS_RETRY_COOLDOWN_MS;
|
||||
fake.built[0].setPov({ heading: 10, pitch: 0 });
|
||||
await settle();
|
||||
assert.equal(calls, 3, 'successful details stay cached');
|
||||
});
|
||||
|
||||
test('a failed panorama does not delay details for another panorama', async (t) => {
|
||||
t.mock.method(Date, 'now', () => 1_000);
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
if (request.pano === 'pano-a') return Promise.reject(new Error('offline'));
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-a');
|
||||
fake.built[0].setPano('pano-b');
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
|
||||
assert.deepEqual(requests, ['pano-a', 'pano-b']);
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
fake.built[0].setPano('pano-a');
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
requests,
|
||||
['pano-a', 'pano-b'],
|
||||
'returning respects the pause',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user