From c5b1d41cbade33efd598286a104e49b9cd1d2952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:30:49 +0200 Subject: [PATCH 001/163] fix(search): geocode through the server, not the browser - Google's Geocoding web service refuses referrer-restricted keys, so the browser call forced GOOGLE_MAPS_API_KEY to ship unrestricted - add /api/google/geocode and /api/google/reverse-geocode, using the server key like Places and Street View already do - the browser key now needs the Map Tiles API only --- CHANGELOG.md | 9 ++ SECURITY.md | 6 +- docs/CURRENT-STATE.md | 19 ++- server/providers/local.js | 3 + server/providers/places.js | 7 +- server/providers/places/google-key.js | 33 +++- server/providers/places/google.js | 155 ++++++++++++++++++ src/annotations/annotationEngine.test.mjs | 2 +- src/annotations/annotationResolver.test.mjs | 20 +-- src/googleGeocodeProxy.test.mjs | 167 ++++++++++++++++++++ src/googleServerKey.test.mjs | 16 +- src/search/defaults.js | 23 +-- src/search/http.js | 15 +- src/search/placeSearch.test.mjs | 2 +- src/voice/gevActions.test.mjs | 6 +- 15 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 src/googleGeocodeProxy.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 744f4be0a..526a89bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Move Google geocoding behind the local server. Forward and reverse lookups now + go through `/api/google/geocode` and `/api/google/reverse-geocode` with the + server key, joining Places and Street View. Google's Geocoding web service + refuses referrer-restricted keys, so the previous browser call left + `SECURITY.md`'s advice and working search mutually exclusive: the bundled + browser key had to be unrestricted for search to work at all. That key now + needs the Map Tiles API only. Keyless sessions and the Photon/Nominatim + fallback order are unchanged. + - Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode `Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification, while ordinary wheel, line-mode and touch-pinch inputs retain their existing diff --git a/SECURITY.md b/SECURITY.md index a21ea99a4..661e8764f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,18 +20,18 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr | `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` | -| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | +| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | ### Two deliberately client-side keys — restrict them These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them: -1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. +1. **Google Maps API key** — loads Photorealistic 3D Tiles directly. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363). 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. > The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side. -**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use. +**Places, Geocoding and Street View never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks. Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9c381ac0c..e038bfe20 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3445,7 +3445,24 @@ Providers normalize coordinates, canonical name, label, place types and optional bounds. Camera framing, nearby landmark recovery and footprint selection remain in their consumers, including the Capitol identity/containment safeguards. Radio keeps localized country names in labels rather than station filters. -Reverse geocoding and nearby/text-search endpoints retain their existing behavior. +Nearby/text-search endpoints retain their existing behavior. + +**Google geocoding is a server route, not a browser call** (#363). Forward and +reverse lookups go to `/api/google/geocode` and `/api/google/reverse-geocode`, +which carry `GOOGLE_MAPS_SERVER_API_KEY` (falling back to `GOOGLE_MAPS_API_KEY`) +and share the Places routes' opt-in `GEV_RATELIMIT_GOOGLE_PER_MIN` throttle. +Google's Geocoding **web service** refuses referrer-restricted keys, so the +previous browser call forced the bundled browser key — visible in devtools by +design — to be left unrestricted; it now needs the Map Tiles API only. Both +routes answer in Google's own `status`/`results` shape rather than the Places +routes' `places: []` contract, because the browser adapters normalize that shape +directly. The browser key still gates the provider client-side, so a keyless +session spends no request to learn it has no Google: the route's own keyless +answer reads as an unanswered provider, which would stop the chain caching the +negative Photon or Nominatim then produces. Its query is capped at 200 +characters, its reverse coordinates are validated against WGS84 bounds before +the request is paid for, and a malformed viewport bias is dropped rather than +refused, since a bias only ranks results. Only valid answers and definitive misses enter bounded caches; malformed replies, HTTP refusals and outages remain retryable. Searches share a 12-second total diff --git a/server/providers/local.js b/server/providers/local.js index 68f61a3cb..07e1a5bb8 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -66,6 +66,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js'; export { googlePlacesContextProxy } from './places.js'; export { googleServerApiKey } from './places.js'; export { keylessGooglePlacesResponse } from './places.js'; +export { keylessGoogleGeocodeResponse } from './places.js'; +export { validateGeocodeAddress } from './places.js'; +export { geocodeBounds } from './places.js'; export { adsbLolFallbackAnchor } from './aircraft/opensky.js'; export { readResponseTextCapped } from './common/http.js'; export { readResponseJsonCapped } from './common/http.js'; diff --git a/server/providers/places.js b/server/providers/places.js index 648be2aa8..6c36627e0 100644 --- a/server/providers/places.js +++ b/server/providers/places.js @@ -1,8 +1,13 @@ /** Place-search and route middleware for Node development servers. */ -export { googlePlacesContextProxy } from './places/google.js'; +export { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from './places/google.js'; export { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './places/google-key.js'; export { installRouteMiddleware } from './places/routes.js'; export { diff --git a/server/providers/places/google-key.js b/server/providers/places/google-key.js index 353bc9e24..a5c54b302 100644 --- a/server/providers/places/google-key.js +++ b/server/providers/places/google-key.js @@ -14,12 +14,33 @@ export function keylessGooglePlacesResponse(apiKey) { } /** - * Google API key for the SERVER-SIDE calls (Places nearby/text search, the - * CCTV Street View fallback). These never reach the browser, so this key can - * be restricted by server IP and scoped to Places API + Street View Static - * API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles + - * Geocoding for the browser (#33). Splitting them is opt-in: unset, this - * falls back to the shared browser key and nothing changes. + * Geocoding speaks Google's own response shape rather than the `places: []` + * contract its two siblings use, because the browser adapters read `status` + * and `results` directly. The keyless capability reads the same way: a status + * that is not `OK` and no results, which the adapters already treat as "this + * provider did not answer" and pass to the next geocoder in the chain. + */ +export function keylessGoogleGeocodeResponse(apiKey) { + if (String(apiKey ?? '').trim()) return null; + return { + statusCode: 200, + payload: { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }, + }; +} + +/** + * Google API key for the SERVER-SIDE calls (Places nearby/text search, + * geocoding, the CCTV Street View fallback). These never reach the browser, so + * this key can be restricted by server IP and scoped to Places API + Geocoding + * API + Street View Static API — while GOOGLE_MAPS_API_KEY stays + * referrer-restricted to Map Tiles for the browser (#33, #363). Splitting them + * is opt-in: unset, this falls back to the shared browser key, which then has + * to carry every API both sides use and cannot take a referrer restriction. */ export function googleServerApiKey() { return resolveGoogleServerKey(process.env); diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 1ee83a892..8895bc19d 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -1,6 +1,7 @@ import { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -42,6 +43,37 @@ export function validatePlacesCoordinates(searchParams) { return { ok: true, latitude, longitude }; } +/** Longest accepted geocoder query. Real place names are far shorter. */ +const GEOCODE_MAX_QUERY = 200; + +/** Validate a free-text geocoder query before consuming request quota. */ +export function validateGeocodeAddress(searchParams) { + const address = String(searchParams.get('address') || '').trim(); + if (!address) return { ok: false, error: 'address is required' }; + if (address.length > GEOCODE_MAX_QUERY) + return { ok: false, error: 'address is too long' }; + return { ok: true, address }; +} + +/** + * Validate the optional viewport bias, which Google reads as + * `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than + * refused: the bias only ranks results, so a malformed one costs relevance, + * not an answer. + */ +export function geocodeBounds(searchParams) { + const raw = String(searchParams.get('bounds') || '').trim(); + if (!raw) return null; + const corners = raw.split('|'); + if (corners.length !== 2) return null; + const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); + if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const [swLat, swLon, neLat, neLon] = numbers; + if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; + if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; + return raw; +} + /** Nearby place labels and view-biased text search, with request-time key resolution. */ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey, @@ -274,6 +306,129 @@ export function googlePlacesContextProxy({ ); } }); + + /** + * Geocoding belongs on the server, not in the page. Google's Geocoding web + * service refuses referrer-restricted keys, so calling it from the browser + * forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be + * left unrestricted (#363). Here the request carries the server key, and + * the browser key no longer needs the Geocoding API at all. + * + * Unlike the two Places routes above, these answer in Google's own shape + * (`status` + `results`) rather than the `places: []` contract: the browser + * normalizers read that shape directly, and keeping it means the proxy adds + * a hop without adding a translation nobody asked for. + */ + function installGeocodeRoute(path, readQuery) { + middlewares.use(path, async (req, res) => { + const refuse = (statusCode, error) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }), + ); + }; + + if (req.method !== 'GET') { + refuse(405, 'Method not allowed'); + return; + } + + // A keyless geocode has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the + // Places routes above). + const apiKey = resolveApiKey(); + const keyless = keylessGoogleGeocodeResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const query = readQuery(requestUrl.searchParams); + if (!query.ok) { + refuse(400, query.error); + return; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with + // the Places routes because both spend the same Google budget. + const _grl = googleRateLimiter(); + if (_grl && !_grl(clientKey(req))) { + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end( + JSON.stringify({ + status: 'OVER_QUERY_LIMIT', + results: [], + error: 'Rate limit exceeded', + }), + ); + return; + } + + try { + const upstream = new URL( + endpoints.geocode || + 'https://maps.googleapis.com/maps/api/geocode/json', + ); + for (const [name, value] of Object.entries(query.params)) + upstream.searchParams.set(name, value); + upstream.searchParams.set('key', apiKey); + const response = await fetchImpl(upstream.toString(), { + redirect: 'error', + }); + const data = await response.json().catch(() => null); + + res.statusCode = response.ok ? 200 : response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.end( + JSON.stringify( + data ?? { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }, + ), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: error?.message || 'Google Geocoding request failed', + }), + ); + } + }); + } + + installGeocodeRoute('/api/google/geocode', (searchParams) => { + const address = validateGeocodeAddress(searchParams); + if (!address.ok) return address; + const bounds = geocodeBounds(searchParams); + return { + ok: true, + params: { + address: address.address, + ...(bounds ? { bounds } : {}), + }, + }; + }); + + installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => { + const coordinates = validatePlacesCoordinates(searchParams); + if (!coordinates.ok) return coordinates; + const { latitude, longitude } = coordinates; + return { ok: true, params: { latlng: `${latitude},${longitude}` } }; + }); } return { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 0d26974ae..cc43b612a 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -400,7 +400,7 @@ test('outline upgrade updates the rendered element in place without remove/add', }; let overpassCall = 0; globalThis.fetch = async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 452d6997c..2c51213c1 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/); + assert.match(String(url), /^\/api\/google\/geocode\?/); return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -273,7 +273,7 @@ for (const fixture of [ const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: fixture.lat, lon: fixture.lon, @@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { const query = new URL(String(url)).searchParams.get('address'); return { json: async () => geocodePayload({ lat: 39.7817, @@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [ { long_name: 'Capitol Hill', types: ['neighborhood', 'political'] }, ]) { installGoogleMocks(t, async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ formatted_address: 'Washington, DC 20004, USA', types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'], @@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; return { ok: true, json: async () => ({ @@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy high: { latitude: 30.28, longitude: -97.68 }, }); // Google is asked first and exactly once; one Photon call answers it. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); }); @@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s installGoogleMocks(t, async (url, init) => { const href = String(url); - if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) }; if (href.startsWith('https://photon.komoot.io/')) { return { @@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t) const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (!photonReachable) throw new Error('network down'); return photonUp; }); diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs new file mode 100644 index 000000000..2518d02cb --- /dev/null +++ b/src/googleGeocodeProxy.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from '../vite.config.js'; + +const UPSTREAM = 'https://geocode.test/json'; + +function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) { + const routes = new Map(); + googlePlacesContextProxy({ + resolveApiKey: () => apiKey, + fetchImpl, + endpoints: { geocode: UPSTREAM }, + }).configureServer({ + middlewares: { use: (name, handler) => routes.set(name, handler) }, + }); + return routes; +} + +function invokeRoute(handler, { method = 'GET', url = '/' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const res = { + statusCode: 200, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: body ? JSON.parse(String(body)) : null, + }); + }, + }; + Promise.resolve( + handler( + { method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, + res, + ), + ).catch(reject); + }); +} + +test('geocoder query and viewport bias are validated before Google is paid', () => { + assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), { + ok: false, + error: 'address is required', + }); + assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), { + ok: false, + error: 'address is required', + }); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })), + { ok: false, error: 'address is too long' }, + ); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })), + { ok: true, address: 'Austin' }, + ); + + // A bias only ranks results, so an unusable one is dropped, not refused. + const bias = '30.1,-97.9|30.4,-97.6'; + assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias); + for (const bounds of [ + '', + '30.1,-97.9', + '30.1,-97.9|30.4', + 'north,-97.9|30.4,-97.6', + '91,-97.9|30.4,-97.6', + '30.1,-181|30.4,-97.6', + ]) + assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); +}); + +test('the geocode routes keep the key server-side and answer in Google shape', async () => { + const upstream = []; + const routes = installRoutes({ + fetchImpl: async (url) => { + upstream.push(new URL(String(url))); + return { + ok: true, + json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), + }; + }, + }); + const forward = routes.get('/api/google/geocode'); + const reverse = routes.get('/api/google/reverse-geocode'); + assert.equal(typeof forward, 'function'); + assert.equal(typeof reverse, 'function'); + + const forwarded = await invokeRoute(forward, { + url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6', + }); + assert.equal(forwarded.statusCode, 200); + assert.deepEqual(forwarded.body, { + status: 'OK', + results: [{ place_id: 'fixture' }], + }); + assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin'); + assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' }); + assert.equal(reversed.statusCode, 200); + assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + // The key travels to Google, never back to the page that asked. + assert.ok(!JSON.stringify(forwarded.body).includes('server-secret')); + assert.ok(!JSON.stringify(reversed.body).includes('server-secret')); + + // A malformed bias is dropped rather than forwarded. + await invokeRoute(forward, { url: '/?address=Austin&bounds=north' }); + assert.equal(upstream.at(-1).searchParams.has('bounds'), false); +}); + +test('a bad or keyless geocode request never reaches Google', async () => { + const upstream = []; + const fetchImpl = async (url) => { + upstream.push(String(url)); + return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + }; + + const routes = installRoutes({ fetchImpl }); + for (const [path, url] of [ + ['/api/google/geocode', '/?address='], + ['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'], + ]) { + const response = await invokeRoute(routes.get(path), { url }); + assert.equal(response.statusCode, 400); + assert.deepEqual(response.body.results, []); + assert.equal(response.body.status, 'REQUEST_DENIED'); + } + + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const rejected = await invokeRoute(routes.get(path), { + method: 'POST', + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(rejected.statusCode, 405); + } + + // Keyless is an empty capability, not an outage: a 200 the browser adapters + // read as "this provider did not answer", so the chain moves on. It resolves + // ahead of validation, like the Places routes: with nothing configured there + // is no request to validate and no quota to protect. + const keyless = installRoutes({ apiKey: ' ', fetchImpl }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) + for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) { + const response = await invokeRoute(keyless.get(path), { url }); + assert.equal(response.statusCode, 200); + assert.equal(response.headers['cache-control'], 'no-store'); + assert.deepEqual(response.body, { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }); + } + + assert.deepEqual(upstream, []); +}); diff --git a/src/googleServerKey.test.mjs b/src/googleServerKey.test.mjs index 0851afcd5..2789219f5 100644 --- a/src/googleServerKey.test.mjs +++ b/src/googleServerKey.test.mjs @@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the } }); -test('both Places routes select the intended key and keep it out of responses', async (t) => { +test('every Google route selects the intended key and keeps it out of responses', async (t) => { const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN }; const calls = []; t.mock.method(globalThis, 'fetch', async (url, options) => { - calls.push({ url, key: options.headers['X-Goog-Api-Key'] }); + // Places sends the key as a header; Geocoding's web service takes it as a + // query parameter. Either way it must be the server key, and never the + // browser's copy of it. + calls.push({ + url, + key: + options?.headers?.['X-Goog-Api-Key'] ?? + new URL(String(url)).searchParams.get('key'), + }); return Response.json({ places: [] }); }); try { @@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses', for (const install of ['configureServer', 'configurePreviewServer']) { const routes = new Map(); googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } }); - assert.equal(routes.size, 2); + assert.equal(routes.size, 4); for (const handler of routes.values()) { const before = calls.length; let body; const res = { setHeader() {}, end(value) { body = value; } }; - await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); + await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); assert.equal(res.statusCode, 200); assert.ok(!body.includes('server-secret')); if (expected) { diff --git a/src/search/defaults.js b/src/search/defaults.js index 2b0428025..247512405 100644 --- a/src/search/defaults.js +++ b/src/search/defaults.js @@ -40,18 +40,23 @@ export function createDefaultPlaceSearch({ ...(selected ? [selected] : [ + // Google answers through the local proxy, which holds the server + // key: its Geocoding web service refuses referrer-restricted keys, + // so calling it from here would force the bundled browser key to be + // left unrestricted (#363). That key still gates the provider, + // because a keyless session must not spend a request to learn it + // has no Google: the proxy's "not configured" reads as an + // unanswered provider, which stops the chain caching the negative + // Photon or Nominatim then produces. createGoogleGeocoder({ request(query, { bias, signal }) { - const key = resolveApiKey?.(); - if (!key) return null; - const url = new URL( - endpoints.geocode || - 'https://maps.googleapis.com/maps/api/geocode/json', + if (!resolveApiKey?.()) return null; + const params = new URLSearchParams({ address: query }); + if (bias) params.set('bounds', bias); + return fetchImpl( + `${endpoints.geocode || '/api/google/geocode'}?${params}`, + { signal }, ); - url.searchParams.set('address', query); - url.searchParams.set('key', key); - if (bias) url.searchParams.set('bounds', bias); - return fetchImpl(url.toString(), { signal }); }, }), createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }), diff --git a/src/search/http.js b/src/search/http.js index 5b1642c67..5afa35b75 100644 --- a/src/search/http.js +++ b/src/search/http.js @@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({ endpoints = {}, } = {}) { const urls = { - reverse: 'https://maps.googleapis.com/maps/api/geocode/json', + // Same-origin like its siblings below: Google's Geocoding web service + // refuses referrer-restricted keys, so the key stays on the server (#363). + reverse: '/api/google/reverse-geocode', textSearch: '/api/google/text-search', nearby: '/api/google/nearby-places', route: '/api/route', @@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({ }, routeProfiles: ['foot', 'car', 'bike'], async reverseGeocode(latitude, longitude, options) { - const key = resolveApiKey?.(); - if (resolveApiKey && !key) return null; + // The browser key gates the provider without travelling with the + // request: the proxy supplies the key Google actually sees. + if (resolveApiKey && !resolveApiKey()) return null; return normalizeGoogleReverse( - await json( - urls.reverse, - { latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) }, - options, - ), + await json(urls.reverse, { lat: latitude, lon: longitude }, options), ); }, async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) { diff --git a/src/search/placeSearch.test.mjs b/src/search/placeSearch.test.mjs index e609f9da9..05e1aaea0 100644 --- a/src/search/placeSearch.test.mjs +++ b/src/search/placeSearch.test.mjs @@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) { const urls = []; const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => { - urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit(); + urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit(); } }); const result = await service.geocode('Hanoi'); assert.equal(result.place.name, 'Hà Nội'); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ba0ed9cf7..1d8d28c97 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3082,7 +3082,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl const requests = []; installKeyedFetch(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }; } assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/); @@ -3107,7 +3107,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9); assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9); // Google is asked first and exactly once; Photon answers unbiased, in one call. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/); assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased'); @@ -3120,7 +3120,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou // "No Radio station matched" for exactly the places it just resolved, while a // keyed install placed a station. The label may carry it; the filter may not. const { calls, dataManager } = radioSelectionHarness(); - installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/') + installKeyedFetch(t, async (url) => (String(url).startsWith('/api/google/geocode') ? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) } : { ok: true, From 771c9e5632ae6f0177adc09bc4ab4c34b4698765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:56:33 +0200 Subject: [PATCH 002/163] fix(search): bound and sanitize the geocode proxy - reject blank viewport-bias components before Number() reads them as 0 - give the upstream call a 10s deadline and a 256 KiB capped body read - return fixed public text on failure instead of the upstream message --- server/providers/places/google.js | 60 +++++++++++++++++++++++-------- src/googleGeocodeProxy.test.mjs | 47 +++++++++++++++++++++--- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 8895bc19d..a4aa48a78 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -4,6 +4,7 @@ import { keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { readResponseJsonCapped } from '../common/http.js'; import { projectNearbyPlaces, projectTextSearchPlaces, @@ -46,6 +47,16 @@ export function validatePlacesCoordinates(searchParams) { /** Longest accepted geocoder query. Real place names are far shorter. */ const GEOCODE_MAX_QUERY = 200; +/** + * Upstream deadline and body ceiling for the geocode routes. The deadline stays + * armed through body consumption, so a stalled upstream cannot hold a handler + * open, and the cap bounds a chunked or length-omitted reply. A geocode answer + * is a handful of results; 256 KiB is far above any honest one. + */ +const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000; + +const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024; + /** Validate a free-text geocoder query before consuming request quota. */ export function validateGeocodeAddress(searchParams) { const address = String(searchParams.get('address') || '').trim(); @@ -66,8 +77,12 @@ export function geocodeBounds(searchParams) { if (!raw) return null; const corners = raw.split('|'); if (corners.length !== 2) return null; - const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); - if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const parts = corners.flatMap((corner) => corner.split(',')); + // A blank component must be refused before Number() turns it into 0 and a + // half-written corner like `30.4,` reads as the equator. + if (parts.length !== 4 || parts.some((part) => !part.trim())) return null; + const numbers = parts.map(Number); + if (!numbers.every(Number.isFinite)) return null; const [swLat, swLon, neLat, neLon] = numbers; if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; @@ -379,31 +394,48 @@ export function googlePlacesContextProxy({ for (const [name, value] of Object.entries(query.params)) upstream.searchParams.set(name, value); upstream.searchParams.set('key', apiKey); + const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS); const response = await fetchImpl(upstream.toString(), { redirect: 'error', + signal: deadline, }); - const data = await response.json().catch(() => null); + // An unreadable, oversized or timed-out body is not an answer: it + // falls through to the same shaped failure as a refusal below. + const data = await readResponseJsonCapped( + response, + GEOCODE_MAX_RESPONSE_BYTES, + deadline, + ).catch(() => null); + + // A body we could not read is an upstream failure, not an answer with + // a status of its own, so it does not borrow the upstream's code. + if (!data) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }), + ); + return; + } res.statusCode = response.ok ? 200 : response.status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'private, max-age=300'); - res.end( - JSON.stringify( - data ?? { - status: 'UNKNOWN_ERROR', - results: [], - error: 'Google Geocoding request failed', - }, - ), - ); - } catch (error) { + res.end(JSON.stringify(data)); + } catch { + // Fixed public text: a transport, URL or runtime message would + // describe the server to the browser (SECURITY.md, sanitized errors). res.statusCode = 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end( JSON.stringify({ status: 'UNKNOWN_ERROR', results: [], - error: error?.message || 'Google Geocoding request failed', + error: 'Google Geocoding request failed', }), ); } diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs index 2518d02cb..98ffd876e 100644 --- a/src/googleGeocodeProxy.test.mjs +++ b/src/googleGeocodeProxy.test.mjs @@ -73,6 +73,10 @@ test('geocoder query and viewport bias are validated before Google is paid', () 'north,-97.9|30.4,-97.6', '91,-97.9|30.4,-97.6', '30.1,-181|30.4,-97.6', + // A blank component must not read as 0 — that is the equator, not a corner. + '30.1,-97.9|30.4,', + '30.1,|30.4,-97.6', + '30.1,-97.9| ,-97.6', ]) assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); }); @@ -82,10 +86,7 @@ test('the geocode routes keep the key server-side and answer in Google shape', a const routes = installRoutes({ fetchImpl: async (url) => { upstream.push(new URL(String(url))); - return { - ok: true, - json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), - }; + return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] }); }, }); const forward = routes.get('/api/google/geocode'); @@ -123,7 +124,7 @@ test('a bad or keyless geocode request never reaches Google', async () => { const upstream = []; const fetchImpl = async (url) => { upstream.push(String(url)); - return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + return Response.json({ status: 'OK', results: [] }); }; const routes = installRoutes({ fetchImpl }); @@ -165,3 +166,39 @@ test('a bad or keyless geocode request never reaches Google', async () => { assert.deepEqual(upstream, []); }); + +test('an upstream failure reaches the browser as fixed public text', async () => { + const routes = installRoutes({ + fetchImpl: async () => { + throw new Error('connect ECONNREFUSED 10.0.0.7:443'); + }, + }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const response = await invokeRoute(routes.get(path), { + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); + } +}); + +test('an oversized upstream body is dropped rather than buffered', async () => { + const routes = installRoutes({ + // Two hundred times the 256 KiB ceiling, declared honestly. + fetchImpl: async () => + Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }), + }); + const response = await invokeRoute(routes.get('/api/google/geocode'), { + url: '/?address=Austin', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); +}); From 0da4d390eaf5b54cac0efeb269879d92e6a562bd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 26 Sep 2026 17:36:59 +0700 Subject: [PATCH 003/163] docs: add the realtime backend to the voice ownership table The ownership table lists every owner except the backend, though realtimeBackend.js owns the two requests, their separate transports, the abort/timeout scope around both, and client-secret expiry. The method signatures are already documented with application construction, so link there rather than repeat them, and state only the ownership facts written nowhere: the connection is the backend's only consumer in the application, the resolved credential is opaque to it and is handed to negotiate whole, and client-secret expiry is refused inside the backend. --- docs/VOICE-OWNERSHIP.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index d5553df00..be7a6c26b 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged. | Owner | State and responsibility | | --- | --- | | `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element | +| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry | | `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation | | `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers | | `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime | @@ -15,6 +16,15 @@ The session adapter, action runner and backend interfaces remain unchanged. | `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation | | `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics | +The backend is replaceable per deployment: `realtimeController.js` accepts it as a +`backend` option and falls back to `createRealtimeBackend()`. Its two method +signatures live with [application construction](APPLICATION.md); what belongs here +is the ownership. The connection is the backend's only consumer in the application. +The resolved credential is opaque to the connection and is handed to `negotiate` +whole. Client-secret expiry is refused inside the backend, not by the connection. +`realtimeOwners.test.mjs` drives whole sessions through a substitute that +implements only those two methods. + Pure input policy, preferences and protocol-response policy have separate modules. Owners receive named readers, operations or focused collaborators. Cross-owner cancellation uses operations; response handling does not own Radio's reservations From 993b1bcdb1d7871a1f39b7cabc7c16387d93dd5d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:19 -0500 Subject: [PATCH 004/163] docs: add Pinokio browser performance workaround --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 88690d52f..0ff6c32e1 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ locked dependencies, finds a free local port, and opens the app. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). +**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +compatibility or minimum-hardware problem, copy the local server URL shown by +Pinokio and open the same URL in your normal hardware-accelerated browser. A +Pinokio 8.2.0 Linux report reproduced severe stutter in Pinokio's embedded view +while the same running server was smooth in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That isolates the browser/compositor path from the app server and your machine; +it does not establish a minimum GPU requirement. + ### Path 2 — Terminal / coding agent Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about From 4ab530b8a83c6f56b34612c1ea5c9bc07c34e3ea Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:21 -0500 Subject: [PATCH 005/163] docs: distinguish embedded browser performance --- docs/PERFORMANCE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 34b207f79..873ef8768 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application: 6. Record live object counts before attributing a difference to the client. 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. +8. Record whether the app is running in Pinokio's embedded view or in an + external browser. The same Pinokio 8.2.0 Linux server was reported as + severely stuttery in the embedded view and smooth when its local URL was + opened directly in a normal browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). + Treat that as a browser/compositor-path difference until the GPU renderer + proves otherwise. ## What is not established yet From c87f007e34a627bc9554c4bfe8070267f9c5e0b5 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 11:39:19 +0700 Subject: [PATCH 006/163] fix(build): discover unit tests under server/, not only src/ npm test is the only suite CI runs, and its discovery walked src/ alone, so server/providers/transitHistory.test.mjs has never executed since it was added. Its 8 tests pass, so this was silent coverage loss rather than a hidden failure: the file reads like covered code in review and never ran anywhere. Add server/ to the searched roots, and pin the property with a test that walks the repository itself and compares the two sets, so a tree added later without updating the roots fails loudly instead of going quiet. Suite goes from 5090 to 5099 tests, 0 failures: the 8 tests in that file plus the regression test added here. --- scripts/run-unit-tests.mjs | 14 ++++++++-- src/unitTestRunner.test.mjs | 51 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 9bd7e70e0..7024ea38c 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -21,9 +21,19 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { return version; } +/** + * Directories this runner searches for `*.test.mjs`, in stable order. + * + * `server/` belongs here because provider modules own tests of their own: a + * suite placed beside the module it covers must run, and `npm test` is the only + * thing CI invokes, so a directory missing from this list is a suite that never + * runs anywhere. `unitTestRunner.test.mjs` asserts the list covers every test + * file in the repository, so adding a tree without adding it here fails. + */ +export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']); + /** Discover repository unit tests in stable path order. */ export function discoverUnitTestFiles(root = process.cwd()) { - const sourceRoot = path.join(root, 'src'); const files = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -34,7 +44,7 @@ export function discoverUnitTestFiles(root = process.cwd()) { } } }; - visit(sourceRoot); + for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot)); return files.sort(); } diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 002dcb796..201f6ed8e 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -1,14 +1,40 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ALLOCATION_TEST_FILES, + UNIT_TEST_ROOTS, allocationTestArgs, assertNode24AllocationRuntime, buildUnitTestPlan, + discoverUnitTestFiles, isCalibratedAllocationRuntime, } from '../scripts/run-unit-tests.mjs'; +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + +/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */ +function testFilesOnDisk(root) { + // Build outputs and dependencies are not places a contributor writes a test, + // and walking them would be slow enough to matter. Everything else counts. + const skip = new Set(['node_modules', 'dist', 'build', 'public']); + const found = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || skip.has(entry.name)) continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile() && entry.name.endsWith('.test.mjs')) { + found.push(path.relative(root, absolute).split(path.sep).join('/')); + } + } + }; + visit(root); + return found.sort(); +} + test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => { const ordinary = [ 'src/data/manager.test.mjs', @@ -37,6 +63,29 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = ); }); +test('the runner discovers every test file in the repository', () => { + // `npm test` is the only suite CI runs, and it runs exactly what this + // function returns. A `*.test.mjs` outside the searched roots therefore never + // executes anywhere, while still reading — in review and to its author — like + // covered code. Compare the two sets rather than their sizes, so the failure + // names the files instead of only disagreeing about a count. + const discovered = discoverUnitTestFiles(repositoryRoot); + const onDisk = testFilesOnDisk(repositoryRoot); + const undiscovered = onDisk.filter((file) => !discovered.includes(file)); + assert.deepEqual( + undiscovered, + [], + `test files the runner never executes: ${undiscovered.join(', ')}`, + ); + assert.deepEqual(discovered, onDisk); + for (const file of discovered) { + assert.ok( + UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)), + `${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`, + ); + } +}); + test('allocation runtime calibration is explicit and pinned to Node 24', () => { assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); assert.throws( From de4c8343ddd3ce3a164e0d44e63cf3c7e805f477 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:46 -0500 Subject: [PATCH 007/163] test: make Recent Imagery live gate panel-relative --- scripts/qa-recent-imagery.mjs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 1460ed3b5..f9601432f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -308,15 +308,21 @@ const CONTROLS = { }; const measure = () => page.evaluate((controls) => { + const panel = document.getElementById('recent-imagery-panel'); const body = document.getElementById('recent-imagery-panel-body'); + const panelRect = panel?.getBoundingClientRect(); const out = { bodyScroll: body?.scrollTop ?? null, rects: {} }; for (const [name, selector] of Object.entries(controls)) { const rect = document.querySelector(selector)?.getBoundingClientRect(); - out.rects[name] = rect - ? [rect.left, rect.top, rect.width, rect.height].map( - (v) => Math.round(v * 10) / 10, - ) - : null; + out.rects[name] = + rect && panelRect + ? [ + rect.left - panelRect.left, + rect.top - panelRect.top, + rect.width, + rect.height, + ].map((v) => Math.round(v * 10) / 10) + : null; } return out; }, CONTROLS); @@ -1042,13 +1048,13 @@ try { ) <= target * 0.01, fitHeight, - 2_000, + 4_000, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`, + `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1258,7 +1264,7 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + const tail = (key) => new Set(samples.slice(-8).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 8d1f69a8731b6cab3fb95f1bf6d5cd05d89f7e85 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:28 -0500 Subject: [PATCH 008/163] ci: prove Recent Imagery gate repair --- .../recent-imagery-gate-repair-proof.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml new file mode 100644 index 000000000..b613d9561 --- /dev/null +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -0,0 +1,37 @@ +name: Recent Imagery gate repair proof + +on: + push: + branches: + - fix/recent-imagery-qa-relative-layout-20261003 + +permissions: + contents: read + +jobs: + browser-proof: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - name: Start GEV + run: | + npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & + for i in {1..90}; do + if curl -fsS http://127.0.0.1:4173/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/gev-dev.log + exit 1 + - name: Recent Imagery live gate + run: node scripts/qa-recent-imagery.mjs + - name: Dev log on failure + if: failure() + run: cat /tmp/gev-dev.log From 7485b16ded0158b804f1ac07da7e25b71eae0d5d Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 5 Oct 2026 17:12:41 +0700 Subject: [PATCH 009/163] fix(overpass): do not serve cached answers from a different upstream (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both disk caches fed by OVERPASS_UPSTREAMS key only on the query — the Overpass proxy on the sanitized request body, the installations proxy on the bbox — so an entry written against one instance is a hit for the same query against another. After an operator repoints OVERPASS_UPSTREAMS, the previous upstream's answers keep being served for 7 days, 30 for boundary-class queries, and indefinitely through the serve-stale path. An install that predates #742 has a cache written from the public instances that were the default then, and configuring your own instance does not displace it. Cached answers now carry a tag identifying the configured upstream set, and a disk read refuses an entry that does not match it, on the stale path too. With nothing configured any entry is still accepted: there is no instance to contradict, and serving last-good data at any age is what the not-configured path exists for. The tag is a hash of the resolved endpoint list, never the URLs; it is written to disk and compared on read, and is not sent to clients or surfaced in errors. One line is logged once per process when a foreign entry is ignored, naming the variable and what will happen, as local-receivers.js already does for a rejected feed. Cost worth naming: on upgrade every existing entry is untagged, so the first outage after upgrading serves nothing where it previously served stale data, until each query is fetched once from the configured instance. --- server/providers/military-installations.js | 13 +++- .../providers/military-installations/cache.js | 4 ++ server/providers/overpass/cache.js | 6 ++ server/providers/overpass/constants.js | 63 +++++++++++++++++-- server/providers/overpass/transport.js | 4 ++ src/overpassProxy.test.mjs | 52 ++++++++++++++- 6 files changed, 135 insertions(+), 7 deletions(-) diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 08ad87d9f..7d252b8ed 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -9,7 +9,10 @@ import { fetchOverpassPayload, overpassNotConfigured, } from './overpass/transport.js'; -import { resolveOverpassUpstreams } from './overpass/constants.js'; +import { + resolveOverpassUpstreams, + resolveOverpassUpstreamTag, +} from './overpass/constants.js'; import { _militaryInstallationCache, trimMilitaryInstallationCache, @@ -74,7 +77,13 @@ function militaryInstallationsProxy() { retrievedAt: new Date().toISOString(), status: 'ready', }; - const entry = { payload, cachedAt: Date.now() }; + // Tagged with the configured upstream set so the disk layer does not serve + // this answer back after the operator repoints (see overpass/cache.js). + const entry = { + payload, + cachedAt: Date.now(), + upstreams: resolveOverpassUpstreamTag(), + }; _militaryInstallationCache.set(key, entry); trimMilitaryInstallationCache(); writeMilitaryInstallationDisk(key, entry); diff --git a/server/providers/military-installations/cache.js b/server/providers/military-installations/cache.js index c77627e29..4d77981e6 100644 --- a/server/providers/military-installations/cache.js +++ b/server/providers/military-installations/cache.js @@ -5,6 +5,7 @@ import { MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, } from './constants.js'; +import { cachedByConfiguredUpstream } from '../overpass/constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { promises as fsp } from 'node:fs'; @@ -114,6 +115,9 @@ async function readMilitaryInstallationDisk( const entry = JSON.parse( await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'), ); + // Same reason as the Overpass disk cache: the key is the bbox alone, so an + // entry written against a different OVERPASS_UPSTREAMS is otherwise a hit. + if (!cachedByConfiguredUpstream(entry)) return null; if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null; return migrateMilitaryInstallationEntry(entry); } catch { diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index b4e53ab21..442f599a6 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -5,6 +5,7 @@ import { OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES, + cachedByConfiguredUpstream, } from './constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; @@ -47,6 +48,11 @@ async function readOverpassDisk(cacheKey, maxAgeMs) { // Older versions persisted 4xx refusals with normal data TTLs. Ignore // them on both fresh and stale reads so an upgrade can recover immediately. if (!overpassPayloadIsData(payload)) return null; + // The key is the query alone, so a file written by a different upstream — + // including the public instances that were the default before #742 — is a + // hit for the same query. Refuse it: the operator's configured instance + // must be what answers, on the stale path too. + if (!cachedByConfiguredUpstream(payload)) return null; if (Date.now() - payload.cachedAt > maxAgeMs) return null; return payload; } catch { diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index 40f244d11..2a0d4a3ef 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { createHash } from 'node:crypto'; // --------------------------------------------------------------------------- // Overpass API proxy constants and cache state @@ -27,16 +28,68 @@ function parseOverpassUpstreams(raw) { return endpoints.slice(0, 8); } -let upstreamMemo = { raw: null, endpoints: [] }; +let upstreamMemo = { raw: null, endpoints: [], tag: 'none' }; + +/** + * Short, stable identifier for the configured upstream set. Cached responses + * carry it so an answer produced by a different instance is not served after + * the operator repoints `OVERPASS_UPSTREAMS` — the disk cache outlives the + * configuration change by 7 days, a month for boundary queries, and forever on + * the serve-stale path. It is a hash, never the URLs, and it is only ever + * compared on read: it is not sent to clients and does not appear in errors. + */ +function upstreamTag(endpoints) { + if (!endpoints.length) return 'none'; + return createHash('sha1') + .update(endpoints.join('\n')) + .digest('hex') + .slice(0, 12); +} /** Resolve after environment loading. Public Overpass instances are not used by default. */ function resolveOverpassUpstreams() { - const raw = process.env.OVERPASS_UPSTREAMS || ''; - if (upstreamMemo.raw !== raw) - upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) }; + readUpstreamMemo(); return [...upstreamMemo.endpoints]; } +/** The configured-upstream tag stored alongside, and checked against, cache entries. */ +function resolveOverpassUpstreamTag() { + return readUpstreamMemo().tag; +} + +let reportedForeignCache = false; + +/** + * Was this cached entry produced by the upstream set configured now? + * + * With nothing configured there is no instance to contradict, and serving + * last-good data at any age is the point of the cache (the proxy's + * not-configured path depends on it), so any entry is accepted. Once an + * operator does configure an instance, only its own answers are served: an + * entry with no tag, or one from a different instance, is refused rather than + * matched on the query alone. Shared by both caches fed by these upstreams. + */ +function cachedByConfiguredUpstream(entry) { + const tag = resolveOverpassUpstreamTag(); + if (tag === 'none' || entry?.upstreams === tag) return true; + if (!reportedForeignCache) { + reportedForeignCache = true; + console.warn( + '[Overpass] ignoring cached responses produced by a different OVERPASS_UPSTREAMS; they will be refetched from the configured instance.', + ); + } + return false; +} + +function readUpstreamMemo() { + const raw = process.env.OVERPASS_UPSTREAMS || ''; + if (upstreamMemo.raw !== raw) { + const endpoints = parseOverpassUpstreams(raw); + upstreamMemo = { raw, endpoints, tag: upstreamTag(endpoints) }; + } + return upstreamMemo; +} + /** * TTL for FRESH cached Overpass responses (ms). Road geometry is static for * months — the original 45 s TTL forced a public-mirror round-trip on nearly @@ -153,6 +206,8 @@ export { OVERPASS_MAX_RESPONSE_BYTES, parseOverpassUpstreams, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, + cachedByConfiguredUpstream, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, }; diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 86b9eae15..38c31bb08 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -1,6 +1,7 @@ import { OVERPASS_MAX_RESPONSE_BYTES, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, } from './constants.js'; @@ -187,6 +188,9 @@ async function fetchOverpassPayload( contentType: 'application/json', // Never retain a secret-bearing endpoint in cache or response metadata. endpoint: 'configured', + // Which configured instance produced this, as a hash rather than a URL, + // so a cached answer is not reused after the operator repoints. + upstreams: resolveOverpassUpstreamTag(), rateLimited: false, }; } catch { diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs index 71b62aa2d..ed4539fda 100644 --- a/src/overpassProxy.test.mjs +++ b/src/overpassProxy.test.mjs @@ -12,6 +12,7 @@ import { createHash, randomUUID } from 'node:crypto'; import path from 'node:path'; import { Readable } from 'node:stream'; import createViteConfig, { fetchOverpassPayload, overpassPayloadIsData, readOverpassDisk } from '../vite.config.js'; +import { resolveOverpassUpstreamTag } from '../server/providers/overpass/constants.js'; const DATA = { status: 200, body: '{"elements":[]}' }; @@ -155,7 +156,13 @@ test('coalesced outage callers both receive last-good data, never a cached refus const directory = path.join(process.cwd(), '.gev-cache', 'overpass'); const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`); await mkdir(directory, { recursive: true }); - const stale = { ...DATA, cachedAt: Date.now() - 40 * 86400000 }; + // Last-good data was fetched from the instance configured above, so it + // carries that instance's tag the way the transport now writes it. + const stale = { + ...DATA, + cachedAt: Date.now() - 40 * 86400000, + upstreams: resolveOverpassUpstreamTag(), + }; await writeFile(file, JSON.stringify(stale)); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -188,3 +195,46 @@ test('coalesced outage callers both receive last-good data, never a cached refus } } }); + +test('a disk entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => { + const prior = process.env.OVERPASS_UPSTREAMS; + t.after(() => { + if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS; + else process.env.OVERPASS_UPSTREAMS = prior; + }); + const key = `overpass-upstream-switch-${randomUUID()}`; + const directory = path.join(process.cwd(), '.gev-cache', 'overpass'); + const file = path.join( + directory, + `${createHash('sha1').update(key).digest('hex')}.json`, + ); + await mkdir(directory, { recursive: true }); + try { + // Written while one instance was configured... + process.env.OVERPASS_UPSTREAMS = 'https://first.example/api/interpreter'; + const first = resolveOverpassUpstreamTag(); + await writeFile( + file, + JSON.stringify({ ...DATA, cachedAt: Date.now(), upstreams: first }), + ); + assert.ok(await readOverpassDisk(key, 60000), 'its own entry is a hit'); + + // ...is not an answer once the operator points at a different one, on the + // stale path either: the key is the query alone, so it would otherwise hit. + process.env.OVERPASS_UPSTREAMS = 'https://second.example/api/interpreter'; + assert.notEqual(resolveOverpassUpstreamTag(), first, 'tag follows the env'); + assert.equal(await readOverpassDisk(key, 60000), null, 'fresh read refused'); + assert.equal( + await readOverpassDisk(key, Infinity), + null, + 'stale read refused', + ); + + // With nothing configured there is no instance to contradict, and serving + // last-good data is what the not-configured path exists for. + delete process.env.OVERPASS_UPSTREAMS; + assert.ok(await readOverpassDisk(key, Infinity), 'unconfigured still serves'); + } finally { + await unlink(file); + } +}); From 42543c1fa1f547db940dd46b0f5c781cff11be9a Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:13:53 +0200 Subject: [PATCH 010/163] feat(bikeshare): add Oslo, Bergen and Trondheim Bysykkel Add Norway's three Urban Sharing city-bike systems to the Bike Share layer and get_bike_share. The feeds are keyless GBFS on gbfs.urbansharing.com, which joins the proxy allowlist. The proxy now sends the Client-Identifier header the operators ask clients to send. Attributed under NLOD 2.0 in DATA_SOURCES.md and the data credits. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ DATA_SOURCES.md | 1 + docs/CURRENT-STATE.md | 5 ++- server/providers/gbfs.js | 6 ++++ src/data/dataCredits.js | 7 ++++ src/data/gbfsProxy.test.mjs | 16 +++++++++ src/data/gbfsSource.js | 1 + src/layers/bikeshare/proxyRoute.test.mjs | 44 ++++++++++++++++++++++++ src/layers/bikeshare/registry.js | 44 ++++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 25 ++++++++++++++ 10 files changed, 154 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..a2fc2d1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and + Trondheim Bysykkel (Urban Sharing, ~440 stations). The feeds are keyless + GBFS on `gbfs.urbansharing.com`, which joins the proxy allowlist; the proxy + now sends the `Client-Identifier: gods-eye-view` header the operators ask + for. Attributed under NLOD 2.0. `get_bike_share` covers the three cities too. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..07782fd5e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -56,6 +56,7 @@ How to read this: | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..8a25ccb8c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -961,6 +961,9 @@ The station reader addresses the proxy as `/api/gbfs/`; the feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. +Every upstream request carries `Client-Identifier: gods-eye-view`, which Urban +Sharing asks of clients of the Oslo, Bergen and Trondheim Bysykkel feeds +(`gbfs.urbansharing.com`); other operators ignore it. ## Remaining local service modules @@ -2922,7 +2925,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 9741bb4e6..f55299992 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -11,6 +11,9 @@ import { /** Upstream fetch timeout for GBFS requests (ms). */ const GBFS_PROXY_TIMEOUT_MS = 12000; +/** Value of the Client-Identifier header sent with every upstream request. */ +export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view'; + export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB function gbfsRedirectHost(location, requestUrl) { @@ -60,6 +63,9 @@ export async function fetchGbfsUpstream( headers: { Accept: 'application/json', 'User-Agent': 'gods-eye-view-gbfs-proxy/1.0', + // Urban Sharing (the Norwegian city-bike feeds) asks every client to + // name itself in this header; other operators ignore it. + 'Client-Identifier': GBFS_CLIENT_IDENTIFIER, }, redirect: 'manual', signal: controller.signal, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..87358dafc 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -251,6 +251,13 @@ export const DATA_CREDITS = [ key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', }, + { + key: 'urbansharing-gbfs', + html: + 'City bikes (Norway): contains data under the ' + + 'Norwegian licence for Open Government data (NLOD) ' + + 'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel', + }, { key: 'osrm-routing', // The service asks for its attribution to carry a "fix the map" link, so diff --git a/src/data/gbfsProxy.test.mjs b/src/data/gbfsProxy.test.mjs index 191cb57ba..af107b38e 100644 --- a/src/data/gbfsProxy.test.mjs +++ b/src/data/gbfsProxy.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + GBFS_CLIENT_IDENTIFIER, GBFS_MAX_BODY_BYTES, fetchGbfsUpstream, } from '../../server/providers/gbfs.js'; @@ -56,6 +57,21 @@ test('GBFS upstream fetch passes a direct 200 through unchanged', async () => { }); }); +test('GBFS upstream fetch names the app in the Client-Identifier header', async () => { + let observedHeaders = null; + await fetchGbfsUpstream( + 'https://gbfs.urbansharing.com/oslobysykkel.no/station_status.json', + { + fetchImpl: async (_url, options) => { + observedHeaders = options.headers; + return new Response(STATION_BODY, { status: 200 }); + }, + }, + ); + assert.equal(GBFS_CLIENT_IDENTIFIER, 'gods-eye-view'); + assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER); +}); + test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => { const result = await fetchGbfsUpstream(STATION_URL, { fetchImpl: async () => new Response(null, { status: 404 }), diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js index a15a806d9..f06fa24f2 100644 --- a/src/data/gbfsSource.js +++ b/src/data/gbfsSource.js @@ -8,6 +8,7 @@ const GBFS_ALLOWED_HOSTS = new Set([ 'austin.publicbikesystem.net', 'hon.publicbikesystem.net', 'chat.publicbikesystem.net', + 'gbfs.urbansharing.com', ]); /** diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index 88b98f31b..9158a6bdf 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -17,6 +17,7 @@ import { createModel } from './model.js'; import { CITY_BY_ID } from './registry.js'; const AUSTIN = CITY_BY_ID.get('austin-capmetro'); +const OSLO = CITY_BY_ID.get('oslo-bysykkel'); const STATION_INFORMATION = { data: { @@ -204,6 +205,49 @@ test('the proxy relays its cache policy and upstream host through the mounted ro ); }); +test('the Norwegian city-bike systems are served through the mounted route', async () => { + for (const [id, systemId] of [ + ['oslo-bysykkel', 'oslobysykkel.no'], + ['bergen-bysykkel', 'bergenbysykkel.no'], + ['trondheim-bysykkel', 'trondheimbysykkel.no'], + ]) { + const system = CITY_BY_ID.get(id); + assert.ok(system, `${id} is registered`); + assert.deepEqual(system.hosts, ['gbfs.urbansharing.com']); + assert.equal( + system.stationStatusUrl, + `https://gbfs.urbansharing.com/${systemId}/station_status.json`, + ); + } + + await withUpstream( + { + [OSLO.stationInformationUrl]: STATION_INFORMATION, + [OSLO.stationStatusUrl]: STATION_STATUS, + }, + async (requested) => { + const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() }); + const info = await source.getStations(OSLO.stationInformationUrl); + const status = await source.getStations(OSLO.stationStatusUrl); + assert.deepEqual(requested, [ + OSLO.stationInformationUrl, + OSLO.stationStatusUrl, + ]); + assert.equal(info.data.stations.length, 1); + assert.equal(status.data.stations.length, 1); + + const response = await mountGbfsProxy()( + '/api/gbfs/' + encodeURIComponent(OSLO.stationStatusUrl), + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get('x-gbfs-upstream'), + 'gbfs.urbansharing.com', + ); + }, + ); +}); + test('the query-string shape the client used to send is refused by the mounted route', async () => { await withUpstream( { [AUSTIN.stationStatusUrl]: STATION_STATUS }, diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..37b7d2c4e 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -23,6 +23,26 @@ function bcycleEntry({ ...buildBcycleUrls(systemId), }; } +function urbanSharingEntry({ + id, + city, + centerLat, + centerLon, + systemId, + provider, + loadRadiusKm = 100, +}) { + return { + id, + city, + centerLat, + centerLon, + loadRadiusKm, + provider, + stationInformationUrl: `https://gbfs.urbansharing.com/${systemId}/station_information.json`, + stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`, + }; +} const RAW_GBFS_CITY_REGISTRY = [ { id: 'nyc-citibike', @@ -318,6 +338,30 @@ const RAW_GBFS_CITY_REGISTRY = [ centerLon: -119.6982, systemId: 'bcycle_santabarbara', }), + urbanSharingEntry({ + id: 'oslo-bysykkel', + city: 'Oslo, Norway', + centerLat: 59.9139, + centerLon: 10.7522, + systemId: 'oslobysykkel.no', + provider: 'Oslo Bysykkel', + }), + urbanSharingEntry({ + id: 'bergen-bysykkel', + city: 'Bergen, Norway', + centerLat: 60.3913, + centerLon: 5.3221, + systemId: 'bergenbysykkel.no', + provider: 'Bergen Bysykkel', + }), + urbanSharingEntry({ + id: 'trondheim-bysykkel', + city: 'Trondheim, Norway', + centerLat: 63.4305, + centerLon: 10.3951, + systemId: 'trondheimbysykkel.no', + provider: 'Trondheim Bysykkel', + }), ]; function normalizeRegistryEntry(entry) { const id = String(entry?.id || '') diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index af8142305..1626aa3dd 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -429,6 +429,31 @@ test('bike share names the systems it did not search', async () => { ); }); +test('bike share over Oslo searches Oslo Bysykkel only', async () => { + const requested = []; + const result = await composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: async (url) => { + requested.push(url); + return { data: { stations: [] } }; + }, + }, + }, + }).call('get_bike_share', { + area: { lat: 59.9139, lon: 10.7522, radius_km: 5 }, + }); + assert.deepEqual(result.data.systems, ['Oslo, Norway']); + assert.deepEqual(result.data.systems_not_searched, []); + assert.ok( + requested.every((url) => + url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/'), + ), + ); +}); + test('traffic counts only roads inside a radius area and reports missing tiles', async () => { const flow = (segments, partial = false) => ({ getStatus: async () => ({ hasKey: true }), From fa61955cf15cab33e55707aba55efc598f35d6e9 Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:02:37 +0200 Subject: [PATCH 011/163] =?UTF-8?q?feat(bikeshare):=20add=20Kolumbus,=20Fa?= =?UTF-8?q?rte=20and=20Lillestr=C3=B8m=20city=20bikes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover every Norwegian station-based city-bike system with an open GBFS feed: Kolumbus Bysykkel (Stavanger) and Farte Bysykkel (Skien/Porsgrunn) through Entur Mobility, and Bysykkel Lillestrøm through JCDecaux Cyclocity. api.entur.io and api.cyclocity.fr join the GBFS allowlist, each kept to its GBFS path prefix since both hosts front other APIs. Entur serves its feeds without a .json suffix, so the endpoint checks accept either form, and requests to Entur carry the ET-Client-Name header it requires. Attributed under NLOD (Entur) and Licence Ouverte (JCDecaux). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++-- DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 10 +++- server/providers/gbfs.js | 4 +- src/data/dataCredits.js | 13 ++++++ src/data/gbfsProxy.test.mjs | 20 ++++++++ src/data/gbfsSource.js | 59 ++++++++++++++++++++++-- src/layers/bikeshare/proxyRoute.test.mjs | 52 +++++++++++++++++++++ src/layers/bikeshare/registry.js | 58 +++++++++++++++++++++-- src/tools/queries/mobility.test.mjs | 28 +++++++++-- 10 files changed, 241 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2fc2d1e2..184e55230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ ## [Unreleased] - Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and - Trondheim Bysykkel (Urban Sharing, ~440 stations). The feeds are keyless - GBFS on `gbfs.urbansharing.com`, which joins the proxy allowlist; the proxy - now sends the `Client-Identifier: gods-eye-view` header the operators ask - for. Attributed under NLOD 2.0. `get_bike_share` covers the three cities too. + Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and + Farte Bysykkel in Skien/Porsgrunn (Entur Mobility), and Bysykkel + Lillestrøm (JCDecaux), about 760 stations in all. Every feed is keyless + GBFS. `gbfs.urbansharing.com`, `api.entur.io` and `api.cyclocity.fr` join + the proxy allowlist; the last two are kept to their GBFS paths. The proxy + now sends `Client-Identifier: gods-eye-view` on every request and + `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, + and accepts Entur's extensionless feed paths. Attributed under NLOD and + Licence Ouverte. `get_bike_share` covers the six systems too. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 07782fd5e..f7e283117 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -57,6 +57,8 @@ How to read this: | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | +| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | +| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a25ccb8c..55056d2ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -963,7 +963,13 @@ streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. Every upstream request carries `Client-Identifier: gods-eye-view`, which Urban Sharing asks of clients of the Oslo, Bergen and Trondheim Bysykkel feeds -(`gbfs.urbansharing.com`); other operators ignore it. +(`gbfs.urbansharing.com`); other operators ignore it. Requests to Entur +(`api.entur.io`, Kolumbus and Farte Bysykkel) also carry +`ET-Client-Name: gods-eye-view-bikeshare`. Entur serves its feeds without a +`.json` suffix, so the endpoint check accepts `station_information` and +`station_status` with or without it. Hosts that front more than GBFS are kept to +their GBFS tree: `api.entur.io` to `/mobility/v2/gbfs/` and `api.cyclocity.fr` +(Bysykkel Lillestrøm) to `/contracts/`. ## Remaining local service modules @@ -2925,7 +2931,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index f55299992..990642c8b 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -3,6 +3,7 @@ import { isAllowedGbfsHost, isAllowedGbfsPath, gbfsCacheControl, + gbfsUpstreamHeaders, } from '../../src/data/gbfsSource.js'; // --------------------------------------------------------------------------- @@ -66,6 +67,7 @@ export async function fetchGbfsUpstream( // Urban Sharing (the Norwegian city-bike feeds) asks every client to // name itself in this header; other operators ignore it. 'Client-Identifier': GBFS_CLIENT_IDENTIFIER, + ...gbfsUpstreamHeaders(new URL(url).hostname), }, redirect: 'manual', signal: controller.signal, @@ -187,7 +189,7 @@ export function gbfsProxy() { return; } - if (!isAllowedGbfsPath(upstreamUrl.pathname)) { + if (!isAllowedGbfsPath(upstreamUrl.pathname, upstreamUrl.hostname)) { res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 87358dafc..195d2487e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -258,6 +258,19 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel', }, + { + key: 'entur-mobility-gbfs', + html: + 'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' + + 'Norwegian licence for Open Government data (NLOD) ' + + 'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)', + }, + { + key: 'jcdecaux-gbfs', + html: + 'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' + + 'Licence Ouverte', + }, { key: 'osrm-routing', // The service asks for its attribution to carry a "fix the map" link, so diff --git a/src/data/gbfsProxy.test.mjs b/src/data/gbfsProxy.test.mjs index af107b38e..c95256137 100644 --- a/src/data/gbfsProxy.test.mjs +++ b/src/data/gbfsProxy.test.mjs @@ -72,6 +72,26 @@ test('GBFS upstream fetch names the app in the Client-Identifier header', async assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER); }); +test('GBFS upstream fetch adds ET-Client-Name for Entur and only for Entur', async () => { + const headersFor = async (url) => { + let observed = null; + await fetchGbfsUpstream(url, { + fetchImpl: async (_url, options) => { + observed = options.headers; + return new Response(STATION_BODY, { status: 200 }); + }, + }); + return observed; + }; + const entur = await headersFor( + 'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status', + ); + assert.equal(entur['ET-Client-Name'], 'gods-eye-view-bikeshare'); + assert.equal(entur['Client-Identifier'], GBFS_CLIENT_IDENTIFIER); + const lyft = await headersFor(STATION_URL); + assert.equal(lyft['ET-Client-Name'], undefined); +}); + test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => { const result = await fetchGbfsUpstream(STATION_URL, { fetchImpl: async () => new Response(null, { status: 404 }), diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js index f06fa24f2..8d4980948 100644 --- a/src/data/gbfsSource.js +++ b/src/data/gbfsSource.js @@ -9,6 +9,30 @@ const GBFS_ALLOWED_HOSTS = new Set([ 'hon.publicbikesystem.net', 'chat.publicbikesystem.net', 'gbfs.urbansharing.com', + 'api.entur.io', + 'api.cyclocity.fr', +]); + +/** + * Hosts that serve more than GBFS keep the proxy to their GBFS tree: Entur's + * api.entur.io fronts every Entur API, and api.cyclocity.fr every JCDecaux + * contract endpoint. + */ +const GBFS_HOST_PATH_PREFIXES = new Map([ + ['api.entur.io', '/mobility/v2/gbfs/'], + ['api.cyclocity.fr', '/contracts/'], +]); + +/** + * Identifying headers an operator asks every GBFS client to send. Entur rate + * limits or blocks consumers that do not send ET-Client-Name, in the form + * "company-application". + */ +const GBFS_HOST_HEADERS = new Map([ + [ + 'api.entur.io', + Object.freeze({ 'ET-Client-Name': 'gods-eye-view-bikeshare' }), + ], ]); /** @@ -29,13 +53,40 @@ export function isAllowedGbfsHost(hostname) { } /** - * Only allow station_information.json and station_status.json endpoints. + * Only allow station_information and station_status endpoints. The `.json` + * suffix is optional because Entur serves its feeds without one. When a + * hostname is given and that host serves more than GBFS, the path must also + * sit under the host's GBFS prefix. * * @param {string} pathname + * @param {string} [hostname] * @returns {boolean} */ -export function isAllowedGbfsPath(pathname) { - return /\/station_(information|status)\.json$/i.test(String(pathname || '')); +export function isAllowedGbfsPath(pathname, hostname = '') { + const path = String(pathname || ''); + if (!/\/station_(information|status)(\.json)?$/i.test(path)) return false; + const prefix = GBFS_HOST_PATH_PREFIXES.get( + String(hostname || '') + .trim() + .toLowerCase(), + ); + return !prefix || path.startsWith(prefix); +} + +/** + * Extra request headers an upstream GBFS host asks clients to send. + * + * @param {string} hostname + * @returns {Record} + */ +export function gbfsUpstreamHeaders(hostname) { + return ( + GBFS_HOST_HEADERS.get( + String(hostname || '') + .trim() + .toLowerCase(), + ) || {} + ); } /** @@ -48,7 +99,7 @@ export function isAllowedGbfsPath(pathname) { * @returns {string} Cache-Control header value. */ export function gbfsCacheControl(pathname) { - if (/\/station_information\.json$/i.test(String(pathname || ''))) { + if (/\/station_information(\.json)?$/i.test(String(pathname || ''))) { return 'public, max-age=300'; } return 'no-store'; diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index 9158a6bdf..9bb04ced0 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -248,6 +248,58 @@ test('the Norwegian city-bike systems are served through the mounted route', asy ); }); +test('every Norwegian city-bike system is registered and reachable through the mounted route', async () => { + const expected = { + 'stavanger-kolumbus': + 'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status', + 'skien-farte': + 'https://api.entur.io/mobility/v2/gbfs/v2/fartebysykkel/station_status', + 'lillestrom-bysykkel': + 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json', + }; + const upstream = {}; + for (const [id, statusUrl] of Object.entries(expected)) { + const system = CITY_BY_ID.get(id); + assert.ok(system, `${id} is registered`); + assert.equal(system.stationStatusUrl, statusUrl); + upstream[system.stationInformationUrl] = STATION_INFORMATION; + upstream[system.stationStatusUrl] = STATION_STATUS; + } + + await withUpstream(upstream, async (requested) => { + const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() }); + for (const id of Object.keys(expected)) { + const system = CITY_BY_ID.get(id); + const info = await source.getStations(system.stationInformationUrl); + const status = await source.getStations(system.stationStatusUrl); + assert.equal(info.data.stations.length, 1, `${id} information`); + assert.equal(status.data.stations.length, 1, `${id} status`); + } + assert.equal(requested.length, 6); + + // Entur's extensionless station_information keeps its 5-minute cache. + const kolumbus = CITY_BY_ID.get('stavanger-kolumbus'); + const response = await mountGbfsProxy()( + '/api/gbfs/' + encodeURIComponent(kolumbus.stationInformationUrl), + ); + assert.equal(response.headers.get('cache-control'), 'public, max-age=300'); + }); +}); + +test('the mounted route keeps multi-API hosts to their GBFS tree', async () => { + await withUpstream({}, async (requested) => { + const mounted = mountGbfsProxy(); + for (const target of [ + 'https://api.entur.io/journey-planner/v3/station_status', + 'https://api.cyclocity.fr/other/station_status.json', + ]) { + const response = await mounted('/api/gbfs/' + encodeURIComponent(target)); + assert.equal(response.status, 400, target); + } + assert.deepEqual(requested, []); + }); +}); + test('the query-string shape the client used to send is refused by the mounted route', async () => { await withUpstream( { [AUSTIN.stationStatusUrl]: STATION_STATUS }, diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index 37b7d2c4e..89508d42e 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -43,6 +43,27 @@ function urbanSharingEntry({ stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`, }; } +function enturEntry({ + id, + city, + centerLat, + centerLon, + systemId, + provider, + loadRadiusKm = 100, +}) { + const base = `https://api.entur.io/mobility/v2/gbfs/v2/${systemId}`; + return { + id, + city, + centerLat, + centerLon, + loadRadiusKm, + provider, + stationInformationUrl: `${base}/station_information`, + stationStatusUrl: `${base}/station_status`, + }; +} const RAW_GBFS_CITY_REGISTRY = [ { id: 'nyc-citibike', @@ -362,6 +383,35 @@ const RAW_GBFS_CITY_REGISTRY = [ systemId: 'trondheimbysykkel.no', provider: 'Trondheim Bysykkel', }), + enturEntry({ + id: 'stavanger-kolumbus', + city: 'Stavanger, Norway', + centerLat: 58.9, + centerLon: 5.72, + systemId: 'kolumbusbysykkel', + provider: 'Kolumbus Bysykkel', + }), + enturEntry({ + id: 'skien-farte', + city: 'Skien/Porsgrunn, Norway', + centerLat: 59.16, + centerLon: 9.64, + systemId: 'fartebysykkel', + provider: 'Farte Bysykkel', + loadRadiusKm: 40, + }), + { + id: 'lillestrom-bysykkel', + city: 'Lillestrøm, Norway', + centerLat: 59.9556, + centerLon: 11.0492, + loadRadiusKm: 40, + stationInformationUrl: + 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_information.json', + stationStatusUrl: + 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json', + provider: 'Bysykkel Lillestrøm', + }, ]; function normalizeRegistryEntry(entry) { const id = String(entry?.id || '') @@ -390,11 +440,13 @@ function normalizeRegistryEntry(entry) { ) { throw new Error(`GBFS entry "${id}" must use https URLs`); } - // Validate that URLs end with expected GBFS endpoint filenames - if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) { + // Validate that URLs end with expected GBFS endpoint names (Entur omits .json) + if ( + !/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname) + ) { throw new Error(`GBFS entry "${id}" station_information URL is invalid`); } - if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) { + if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) { throw new Error(`GBFS entry "${id}" station_status URL is invalid`); } diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 1626aa3dd..07482946b 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -429,7 +429,7 @@ test('bike share names the systems it did not search', async () => { ); }); -test('bike share over Oslo searches Oslo Bysykkel only', async () => { +test('bike share over Oslo searches only the nearby Norwegian systems', async () => { const requested = []; const result = await composeCatalog({ tools: coreTools, @@ -445,15 +445,35 @@ test('bike share over Oslo searches Oslo Bysykkel only', async () => { }).call('get_bike_share', { area: { lat: 59.9139, lon: 10.7522, radius_km: 5 }, }); - assert.deepEqual(result.data.systems, ['Oslo, Norway']); + assert.deepEqual(result.data.systems.sort(), [ + 'Lillestrøm, Norway', + 'Oslo, Norway', + ]); assert.deepEqual(result.data.systems_not_searched, []); assert.ok( - requested.every((url) => - url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/'), + requested.every( + (url) => + url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/') || + url.startsWith('https://api.cyclocity.fr/contracts/lillestrom/'), ), ); }); +test('bike share over Stavanger searches Kolumbus Bysykkel', async () => { + const result = await composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: GBFS_CITY_REGISTRY, + getStations: async () => ({ data: { stations: [] } }), + }, + }, + }).call('get_bike_share', { + area: { lat: 58.97, lon: 5.7331, radius_km: 5 }, + }); + assert.deepEqual(result.data.systems, ['Stavanger, Norway']); +}); + test('traffic counts only roads inside a radius area and reports missing tiles', async () => { const flow = (segments, partial = false) => ({ getStatus: async () => ({ hasKey: true }), From f93755af488585bd745f75c7baa43bebbed5dc3e Mon Sep 17 00:00:00 2001 From: Bruno Gomes Date: Tue, 6 Oct 2026 16:13:45 -0300 Subject: [PATCH 012/163] fix(cctv): start live HLS on plain-HTTP LAN pages The client lease id came from crypto.randomUUID, which browsers only expose in secure contexts. Opened over http://:4173 (the HOST=0.0.0.0 opt-in), starting a live camera feed threw before playback. Fall back to a v4 UUID from crypto.getRandomValues, which the server's lease check accepts. --- CHANGELOG.md | 6 ++++ docs/CURRENT-STATE.md | 4 ++- src/layers/cctv/videoPlayback.js | 28 +++++++++++++++++- src/layers/cctv/videoPlayback.test.mjs | 39 +++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..9fc367d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. +- Live HLS cameras play on a page opened over plain HTTP from another machine + (`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which + browsers only expose in secure contexts, so starting a live feed threw + before playback. The id now falls back to a v4 UUID built from + `crypto.getRandomValues`, which the server's lease check accepts. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..fde1373af 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4814,7 +4814,9 @@ its paint loop. Camera deactivation destroys the decoder, and fatal playback errors revert to the labeled frame fallback. Camera changes clear the old panel frame. hls.js is imported only when an HLS camera starts. Two live sessions may coexist with at most eight independent consumer leases each; no-access lease -expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail +expiry is 15 seconds. Closing one consumer leaves other consumers running. Lease +ids use `crypto.randomUUID` where the page is a secure context and an equivalent +v4 id from `crypto.getRandomValues` on plain-HTTP LAN pages. Encrypted/fMP4/byte-range playlists fail closed. Performance and live agency reliability require recorded validation. ## Wind forecast models diff --git a/src/layers/cctv/videoPlayback.js b/src/layers/cctv/videoPlayback.js index d59143b5a..48988c17c 100644 --- a/src/layers/cctv/videoPlayback.js +++ b/src/layers/cctv/videoPlayback.js @@ -1,3 +1,28 @@ +/** + * A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure + * contexts, so a page opened over plain HTTP from another machine (the + * HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context + * and yields the same shape the server's lease check accepts. + */ +export function createLeaseId(cryptoImpl = globalThis.crypto) { + if (typeof cryptoImpl.randomUUID === 'function') { + return cryptoImpl.randomUUID(); + } + const bytes = cryptoImpl.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4), + hex.slice(4, 6), + hex.slice(6, 8), + hex.slice(8, 10), + hex.slice(10), + ] + .map((group) => group.join('')) + .join('-'); +} + /** One decoder per active camera; both surfaces consume this video element. */ export function attachCctvVideo( video, @@ -7,13 +32,14 @@ export function attachCctvVideo( loadHls = () => import('hls.js'), onFailure = () => {}, fetchImpl = globalThis.fetch, + cryptoImpl = globalThis.crypto, } = {}, ) { // Preserve replay for existing finite video feeds; live HLS must not loop. video.loop = feedType !== 'hls'; let disposed = false; let hls = null; - const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null; + const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null; const mediaUrl = feedType === 'hls' ? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}` diff --git a/src/layers/cctv/videoPlayback.test.mjs b/src/layers/cctv/videoPlayback.test.mjs index 1f8c1c0bb..d1ef02539 100644 --- a/src/layers/cctv/videoPlayback.test.mjs +++ b/src/layers/cctv/videoPlayback.test.mjs @@ -1,6 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { attachCctvVideo } from './videoPlayback.js'; +import { webcrypto } from 'node:crypto'; +import { attachCctvVideo, createLeaseId } from './videoPlayback.js'; + +const LEASE_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** What an insecure context exposes: getRandomValues but no randomUUID. */ +const insecureCrypto = { + getRandomValues: (array) => webcrypto.getRandomValues(array), +}; function video() { const v = new EventTarget(); Object.assign(v, { @@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn assert.equal(releases[0].init.method, 'DELETE'); }); +test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => { + assert.match(createLeaseId(), LEASE_V4); + const ids = new Set(); + for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto)); + assert.equal(ids.size, 64); + for (const id of ids) assert.match(id, LEASE_V4); +}); + +test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => { + const source = video(); + source.canPlayType = () => 'probably'; + const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', { + loadHls: async () => ({ default: { isSupported: () => false } }), + fetchImpl: async () => {}, + cryptoImpl: insecureCrypto, + }); + try { + await playback.ready; + const lease = new URL( + source.src, + 'http://192.168.1.10:4173/', + ).searchParams.get('lease'); + assert.match(lease, LEASE_V4); + } finally { + playback.dispose(); + } +}); + test('finite video feeds retain looping while live HLS does not loop', async () => { for (const feedType of ['mp4', 'webm', 'hls']) { const source = video(); From 57b8d8b23c249e05cc122e6b9f9cec9706757439 Mon Sep 17 00:00:00 2001 From: kapturner Date: Sun, 4 Oct 2026 08:26:18 -0500 Subject: [PATCH 013/163] feat: import GeoJSON, KML, KMZ and GPX files onto the globe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DISPLAY ▸ Import (and drop onto the globe) puts the person's own files on the map as Cesium data sources, clamped to the ground, one color per file, with a row to show/hide, fly to or remove each. Files stay in the browser for the session. KML/KMZ are sanitized before Cesium reads them (network links, remote icons/styles, models, photo/screen overlays and balloon HTML removed; KMZ images served from blob URLs), so an imported file cannot make the page fetch anything. KMZ is read with a small dependency-free zip reader on the platform DecompressionStream, with a zip-bomb limit. Bounded file size, entity count and number of files. Tests: src/data/geoFileImport.test.mjs, src/ui/geoFileImport.test.mjs. Gate: scripts/qa-geo-import.mjs. The import half of #890. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + docs/CURRENT-STATE.md | 10 + index.html | 2 +- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 2 + scripts/qa-geo-import.mjs | 402 +++++++++++++++++++++++ src/app/tools.js | 6 + src/data/geoFileImport.js | 432 +++++++++++++++++++++++++ src/data/geoFileImport.test.mjs | 215 ++++++++++++ src/ui/geoFileImport.js | 329 +++++++++++++++++++ src/ui/geoFileImport.test.mjs | 399 +++++++++++++++++++++++ src/ui/styles/controls.css | 64 ++++ src/ui/templates/display-controls.html | 9 + 13 files changed, 1879 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-geo-import.mjs create mode 100644 src/data/geoFileImport.js create mode 100644 src/data/geoFileImport.test.mjs create mode 100644 src/ui/geoFileImport.js create mode 100644 src/ui/geoFileImport.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3876f79..36100f297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. +- Import your own GeoJSON, KML, KMZ and GPX files (DISPLAY ▸ Import, or drop + them onto the globe). Each file is drawn on the ground in its own color with + a row to show, hide, fly to or remove it. Files stay in the browser for the + session, and KML/KMZ network links, remote icons, remote styles and balloon + HTML are left out so an imported file cannot make the page fetch anything. + The import half of #890. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d51be986b..4a3b6a77b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4106,6 +4106,16 @@ are omitted rather than framing the wrong part of the globe. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### Import Your Own Files (October 2026) + +- DISPLAY ▸ **Import** puts GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX files on the globe. Files come in through the Import button (a hidden ``) or by dropping them onto the globe; the format is taken from the extension, then from the first bytes. Each file becomes its own Cesium data source (`GeoJsonDataSource`, `KmlDataSource`, `GpxDataSource`), clamped to the ground, in its own color from the whiteboard palette, with a row to show/hide it, fly to it, or remove it. The first file of a batch that loads is flown to. Imported files last for the page session: a reload starts empty, and nothing is uploaded or stored. +- Runtime entry: `createApplicationTools` (`src/app/tools.js`) calls `initGeoFileImport({ viewer })` (`src/ui/geoFileImport.js`) after the draw tool and defers its `destroy()`, which removes every imported data source, revokes every blob URL, and gives back the drop listeners on the viewer container and the `window.__gevGeoImport` handle (`importFiles`, `list`, `setVisible`, `flyTo`, `remove`, `diagnostics`). Parsing lives in `src/data/geoFileImport.js`. +- **An imported file cannot make the page fetch anything.** Before `KmlDataSource` reads a KML/KMZ document, `sanitizeKmlDocument` removes `NetworkLink`, `NetworkLinkControl`, `ScreenOverlay`, `PhotoOverlay`, `Model` and `Tour`, every `href` that is not an image packed in the same KMZ or a `data:image/` URL (an icon then falls back to Cesium's default pin, and a `GroundOverlay` left without an image is dropped), every `styleUrl` that is not a local `#id`, and placemark `description`/`BalloonStyle`/`Snippet` HTML. KMZ images are served from blob URLs. The hint says when something was left out. `scripts/qa-geo-import.mjs` imports files that name a trap host in each of those places and fails on any request to it. +- **KMZ** is read by a small zip reader (`readZipEntries`: stored and deflated entries through the platform `DecompressionStream`; no encryption, no ZIP64). The main document is `doc.kml`, else the shallowest `.kml`; relative hrefs resolve only to entries inside the archive. +- **Bounds.** A file may be at most 50 MB (`MAX_IMPORT_BYTES`), a KMZ may inflate to at most 120 MB in total (`MAX_KMZ_INFLATED_BYTES`, the zip-bomb stop), one file may create at most 20,000 entities (`MAX_IMPORT_ENTITIES`), and at most 12 files are on the globe at once (`MAX_IMPORTED_FILES`; files over that are not read). A refused file names its reason in the hint and does not stop the rest of the batch; a file with nothing to draw is refused. +- File names are rendered with `textContent` only. +- Gate: `node scripts/qa-geo-import.mjs` (dev server up). Unit: `src/data/geoFileImport.test.mjs`, `src/ui/geoFileImport.test.mjs`. + ### Recent Imagery (September 2026) - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. diff --git a/index.html b/index.html index 2e630c4a8..78d6e34ec 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7062704a7..da80c0749 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -26,6 +26,8 @@ "src/annotations/drawMode.test.mjs", "src/annotations/drawTool.js", "src/annotations/drawTool.test.mjs", + "src/data/geoFileImport.test.mjs", + "src/ui/geoFileImport.test.mjs", "src/annotations/adminOutline.test.mjs", "src/annotations/regionRing.test.mjs", "src/data/adminBoundaries.test.mjs", @@ -195,6 +197,7 @@ "src/styles/snow.js", "scripts/qa-visual-effects.mjs", "scripts/qa-draw-tool.mjs", + "scripts/qa-geo-import.mjs", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/ui/mapSourceControls.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ce778ee95..52062c1b3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1958,6 +1958,8 @@ "src/data/inputOwnership.js", "src/annotations/drawMode.js", "src/annotations/drawTool.js", + "src/data/geoFileImport.js", + "src/ui/geoFileImport.js", "src/data/routeSteps.js", "src/layers/directions/index.js", "src/data/directions.js", diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs new file mode 100644 index 000000000..c1c8f6462 --- /dev/null +++ b/scripts/qa-geo-import.mjs @@ -0,0 +1,402 @@ +#!/usr/bin/env node +/** + * Rendered acceptance for DISPLAY ▸ Import: real files through the real file + * input and a real drop, parsed by Cesium in the page. + * + * Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX + * each become one row and one data source; the imported shape actually paints + * on the globe and stops painting when hidden; a KML/KMZ never makes the page + * request anything named in it (network link, remote icon, remote style); a + * dropped file imports like a picked one; a file that is not geodata is refused + * with a reason and leaves nothing behind; Remove gives every data source back; + * no page errors. + * + * Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`. + * QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; +const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import'; +const HEADFUL = process.env.QA_HEADFUL === '1'; +// Golden Gate Park: a big, flat, green rectangle the test polygon can cover. +const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 }; +const TRAP_HOST = 'gev-qa-trap.invalid'; + +/* ── fixtures ──────────────────────────────────────────────────────────── */ + +const ring = [ + [-122.511, 37.7645], + [-122.4545, 37.7645], + [-122.4545, 37.7745], + [-122.511, 37.7745], + [-122.511, 37.7645], +]; +const geojson = JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { name: 'QA park' }, + geometry: { type: 'Polygon', coordinates: [ring] }, + }, + { + type: 'Feature', + properties: { name: 'QA pin' }, + geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] }, + }, + ], +}); +const kml = (iconHref) => ` + + + + https://${TRAP_HOST}/link.kml + QA KML pin#s + ]]> + -122.47,37.77,0 + QA remote stylehttps://${TRAP_HOST}/styles.kml#x + -122.50,37.766 -122.46,37.772 + https://${TRAP_HOST}/overlay.png + 37.7737.76-122.46-122.47 + +`; +const gpx = ` + + QA waypoint + QA track + + +`; +// 1×1 PNG. +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', +); +/** Stored (uncompressed) zip — enough for a KMZ fixture. */ +function storedZip(files) { + const parts = []; + const central = []; + let offset = 0; + for (const [name, data] of files) { + const n = Buffer.from(name); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(n.length, 26); + const c = Buffer.alloc(46); + c.writeUInt32LE(0x02014b50, 0); + c.writeUInt32LE(data.length, 20); + c.writeUInt32LE(data.length, 24); + c.writeUInt16LE(n.length, 28); + c.writeUInt32LE(offset, 42); + parts.push(local, n, data); + central.push(c, n); + offset += 30 + n.length + data.length; + } + const dir = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(dir.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...parts, dir, end]); +} +const kmzDoc = kml('files/pin.png').replace( + `https://${TRAP_HOST}/overlay.png`, + 'files/overlay.png', +); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-import-')); +const fixture = (name, data) => { + const file = path.join(dir, name); + fs.writeFileSync(file, data); + return file; +}; +const FILES = [ + fixture('park.geojson', geojson), + fixture('remote-bits.kml', kml(`https://${TRAP_HOST}/icon.png`)), + fixture( + 'packed.kmz', + storedZip([ + ['doc.kml', Buffer.from(kmzDoc)], + ['files/pin.png', PNG], + ['files/overlay.png', PNG], + ]), + ), + fixture('walk.gpx', gpx), +]; + +/* ── harness ───────────────────────────────────────────────────────────── */ + +const browser = await puppeteer.launch({ + headless: !HEADFUL, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +const trapped = []; +page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +page.on('request', (request) => { + if (request.url().includes(TRAP_HOST)) trapped.push(request.url()); +}); +let failures = 0; +function check(name, passed, detail = '') { + console.log( + `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`, + ); + if (!passed) failures++; +} +fs.mkdirSync(SHOT_DIR, { recursive: true }); +const shot = (name) => + page.screenshot({ + path: path.join(SHOT_DIR, `${name}.jpg`), + type: 'jpeg', + quality: 82, + }); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const CLIP = { x: 380, y: 240, width: 480, height: 320 }; + +/** Pixels in the clip close to the first import color (#39d0ff). */ +async function cyanPixels() { + await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender()); + await wait(600); + const base64 = await page.screenshot({ + clip: CLIP, + type: 'png', + encoding: 'base64', + }); + return page.evaluate(async (b64) => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/png;base64,${b64}`)).blob(), + ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height); + let n = 0; + for (let i = 0; i < data.length; i += 4) { + const [r, g, b] = [data[i], data[i + 1], data[i + 2]]; + if (b > 150 && g > 120 && r < 140 && b - r > 60) n += 1; + } + return n; + }, base64); +} +const state = () => + page.evaluate(() => ({ + files: window.__gevGeoImport.list(), + dataSources: window.__godsEyeView.viewer.dataSources.length, + rows: document.querySelectorAll('#geo-import-list .geo-import-row').length, + hint: document.getElementById('geo-import-hint')?.textContent || '', + })); + +try { + await page.setViewport({ width: 1280, height: 860 }); + await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__gevGeoImport && + window.__godsEyeView?.viewer && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 90_000 }, + ); + await page.evaluate((view) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: window.__CESIUM__.Cartesian3.fromDegrees( + view.lon, + view.lat, + view.height, + ), + orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, + }); + viewer.scene.requestRender(); + }, VIEW); + await wait(4000); + await page.evaluate(() => { + const rail = document.getElementById('pp-toggles'); + if (rail?.classList.contains('collapsed')) + document + .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]') + ?.click(); + }); + await page.waitForFunction( + () => + document.getElementById('geo-import-button')?.getBoundingClientRect() + .width > 0, + { timeout: 10_000 }, + ); + const before = await state(); + check( + 'Import is on the DISPLAY rail with an empty, hidden list', + before.rows === 0 && + !(await page.evaluate(() => + document + .getElementById('geo-import-list') + .classList.contains('visible'), + )), + ); + const cyanBefore = await cyanPixels(); + + // Picked through the real . + const input = await page.$('#geo-import-input'); + await input.uploadFile(...FILES); + const imported = await page + .waitForFunction(() => window.__gevGeoImport.list().length === 4, { + timeout: 60_000, + }) + .then( + () => true, + () => false, + ); + const after = await state(); + check( + 'four picked files become four rows and four data sources', + imported && + after.rows === 4 && + after.dataSources === before.dataSources + 4, + JSON.stringify( + after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`), + ), + ); + check( + 'formats are recognized', + after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx', + ); + check( + 'the KML and KMZ had their outside references removed', + after.files[1].removed > 0 && after.files[2].removed > 0, + `removed ${after.files[1].removed}/${after.files[2].removed}`, + ); + check( + 'the hint says so', + /Added 4 files\..*left out/.test(after.hint), + after.hint, + ); + await page.evaluate((view) => { + const viewer = window.__godsEyeView.viewer; + viewer.camera.cancelFlight?.(); + viewer.camera.setView({ + destination: window.__CESIUM__.Cartesian3.fromDegrees( + view.lon, + view.lat, + view.height, + ), + orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 }, + }); + }, VIEW); + await wait(2500); + const cyanShown = await cyanPixels(); + await shot('imported'); + check( + 'the imported polygon paints on the globe', + cyanShown > cyanBefore * 4, + `${cyanBefore} → ${cyanShown}`, + ); + + const parkId = after.files[0].id; + await page.click( + `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`, + ); + const cyanHidden = await cyanPixels(); + check( + 'hiding it stops it painting', + cyanHidden < cyanShown / 3, + `${cyanShown} → ${cyanHidden}`, + ); + await page.click( + `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`, + ); + check( + 'showing it again paints it again', + (await cyanPixels()) > cyanHidden * 4, + ); + + // Dropped onto the globe, as a person drags from their file manager. + await page.evaluate((text) => { + const container = window.__godsEyeView.viewer.container; + const transfer = new DataTransfer(); + transfer.items.add( + new File([text], 'dropped.geojson', { type: 'application/geo+json' }), + ); + for (const type of ['dragenter', 'dragover', 'drop']) + container.dispatchEvent( + new DragEvent(type, { + dataTransfer: transfer, + bubbles: true, + cancelable: true, + }), + ); + }, geojson); + const dropped = await page + .waitForFunction(() => window.__gevGeoImport.list().length === 5, { + timeout: 20_000, + }) + .then( + () => true, + () => false, + ); + check('a file dropped on the globe imports', dropped); + + // Not geodata: refused with a reason, nothing added. + await page.evaluate(() => { + const transfer = new DataTransfer(); + transfer.items.add( + new File(['just some notes'], 'notes.txt', { type: 'text/plain' }), + ); + window.__godsEyeView.viewer.container.dispatchEvent( + new DragEvent('drop', { + dataTransfer: transfer, + bubbles: true, + cancelable: true, + }), + ); + }); + await wait(800); + const refused = await state(); + check( + 'a file that is not geodata is refused with a reason', + refused.files.length === 5 && + /notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint), + refused.hint, + ); + + // Remove every file through its row button. + for (const { id } of refused.files) + await page.click( + `#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`, + ); + const cleared = await state(); + check( + 'Remove gives every data source back', + cleared.rows === 0 && cleared.dataSources === before.dataSources, + `${cleared.dataSources} vs ${before.dataSources}`, + ); + await wait(1500); + check( + 'nothing named inside the KML/KMZ was ever requested', + trapped.length === 0, + trapped.join(' '), + ); + check('no page errors', errors.length === 0, errors.join(' | ')); +} catch (error) { + check('harness ran to completion', false, error?.stack || String(error)); +} finally { + await browser.close(); + fs.rmSync(dir, { recursive: true, force: true }); +} +console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed'); +process.exit(failures ? 1 : 0); diff --git a/src/app/tools.js b/src/app/tools.js index 25d763e2d..76f8e7fe6 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -1,6 +1,7 @@ import { SceneDirector } from '../scenes/director.js'; import { initAnnotations } from '../annotations/index.js'; import { initDrawTool } from '../annotations/drawTool.js'; +import { initGeoFileImport } from '../ui/geoFileImport.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; @@ -56,6 +57,11 @@ export function createApplicationTools({ // lifetime rather than to whoever last pressed the button. const drawTool = initDrawTool({ viewer, annotations }); defer(() => drawTool?.destroy()); + // DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its + // own data source. They live for the page session, so the application + // lifetime removes them and the drop listeners on the viewer container. + const geoFileImport = initGeoFileImport({ viewer }); + defer(() => geoFileImport?.destroy()); // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // panel lives on the right rail, so both belong to the application // lifetime. The tileset lets the layer drape while the globe is hidden. diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js new file mode 100644 index 000000000..ee38b7fa1 --- /dev/null +++ b/src/data/geoFileImport.js @@ -0,0 +1,432 @@ +/** + * Your own geodata on the globe: GeoJSON, KML, KMZ and GPX files read in the + * browser and handed to Cesium's own data sources. + * + * Nothing here talks to the network. The file never leaves the page, and a + * KML/KMZ file cannot make the page fetch anything either: network links, + * remote icons, remote styles, models and photo/screen overlays are removed + * before Cesium sees the document (`sanitizeKmlDocument`). Images packed + * inside a KMZ are served to Cesium from blob URLs the caller revokes. + * + * The bounded pieces — format detection, the KMZ (zip) reader, the size and + * count limits — are plain functions so they run under `node --test`; the + * Cesium loaders need a browser DOM and are exercised by + * `scripts/qa-geo-import.mjs`. + */ +import * as Cesium from 'cesium'; + +/** Largest file accepted, in bytes. */ +export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; +/** Largest total a KMZ may inflate to — a zip bomb stops here. */ +export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; +/** Most files on the globe at once. */ +export const MAX_IMPORTED_FILES = 12; +/** Most entities one file may create before it is refused. */ +export const MAX_IMPORT_ENTITIES = 20_000; +/** What the file picker offers. */ +export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx'; + +const GEOJSON_TYPES = new Set([ + 'FeatureCollection', + 'Feature', + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', + 'GeometryCollection', +]); +const KMZ_IMAGE_TYPES = Object.freeze({ + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + bmp: 'image/bmp', +}); +// KML elements that fetch, embed or execute something outside the file. +const KML_REMOVED_ELEMENTS = Object.freeze([ + 'NetworkLink', + 'NetworkLinkControl', + 'ScreenOverlay', + 'PhotoOverlay', + 'Model', + 'Tour', +]); + +/** An error whose message is meant for the person who chose the file. */ +export class GeoImportError extends Error { + constructor(message) { + super(message); + this.name = 'GeoImportError'; + } +} + +/** + * The format of a file, from its name first and its first bytes second. + * @param {string} name File name. + * @param {Uint8Array} [head] The first bytes of the file. + * @returns {'geojson'|'kml'|'kmz'|'gpx'|null} + */ +export function detectGeoFileFormat(name, head) { + const ext = /\.([a-z0-9]+)$/i.exec(String(name || ''))?.[1]?.toLowerCase(); + if (ext === 'geojson') return 'geojson'; + if (ext === 'kml' || ext === 'kmz' || ext === 'gpx') return ext; + if (!head?.length) return ext === 'json' ? 'geojson' : null; + if ( + head[0] === 0x50 && + head[1] === 0x4b && + head[2] === 0x03 && + head[3] === 0x04 + ) + return 'kmz'; + const text = new TextDecoder() + .decode(head.subarray(0, 1024)) + .replace(/^/, '') + .trimStart(); + if (text.startsWith('{')) return 'geojson'; + if (text.startsWith('<')) { + if (/]/i.test(text)) return 'kml'; + if (/]/i.test(text)) return 'gpx'; + } + return ext === 'json' ? 'geojson' : null; +} + +/** + * A short, printable name for the list: the file name without control + * characters, cut to 60 characters. + * @param {string} name + * @returns {string} + */ +export function importDisplayName(name) { + const clean = String(name || '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim(); + if (!clean) return 'Imported file'; + return clean.length > 60 ? `${clean.slice(0, 57)}…` : clean; +} + +/** + * Parse and check GeoJSON text. Throws `GeoImportError` for anything that is + * not a GeoJSON object. + * @param {string} text + * @returns {object} + */ +export function parseGeoJsonText(text) { + let value; + try { + value = JSON.parse(String(text).replace(/^/, '')); + } catch { + throw new GeoImportError('That file is not valid JSON.'); + } + if (!value || typeof value !== 'object' || !GEOJSON_TYPES.has(value.type)) + throw new GeoImportError('That JSON file is not GeoJSON.'); + if (value.type === 'FeatureCollection' && !Array.isArray(value.features)) + throw new GeoImportError('That FeatureCollection has no features list.'); + return value; +} + +/** Inflate raw-deflate bytes with the platform's stream, stopping at `maxBytes`. */ +async function inflateRaw(bytes, maxBytes) { + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream('deflate-raw')); + const reader = stream.getReader(); + const chunks = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > maxBytes) + throw new GeoImportError( + 'That KMZ unpacks to more than the import limit.', + ); + chunks.push(value); + } + } catch (error) { + await reader.cancel().catch(() => {}); + if (error instanceof GeoImportError) throw error; + throw new GeoImportError('That KMZ is damaged and could not be unpacked.'); + } + const out = new Uint8Array(total); + let at = 0; + for (const chunk of chunks) { + out.set(chunk, at); + at += chunk.length; + } + return out; +} + +/** + * Read the entries of a zip archive (stored or deflated; no encryption, no + * ZIP64). Directory entries are skipped. + * @param {Uint8Array} bytes The whole archive. + * @param {{maxInflatedBytes?: number}} [options] + * @returns {Promise>} Entry path → contents. + */ +export async function readZipEntries( + bytes, + { maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {}, +) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const fail = (message = 'That KMZ is not a readable zip archive.') => { + throw new GeoImportError(message); + }; + // End of central directory: 22 bytes plus a comment of up to 64 KiB. + let eocd = -1; + for ( + let i = bytes.length - 22; + i >= Math.max(0, bytes.length - 22 - 0xffff); + i-- + ) { + if (view.getUint32(i, true) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) fail(); + const count = view.getUint16(eocd + 10, true); + const dirSize = view.getUint32(eocd + 12, true); + let at = view.getUint32(eocd + 16, true); + if (count === 0xffff || at === 0xffffffff || at + dirSize > eocd) + fail('That KMZ uses a zip format this import does not read.'); + const entries = new Map(); + const names = new TextDecoder(); + let inflated = 0; + for (let n = 0; n < count; n++) { + if (at + 46 > bytes.length || view.getUint32(at, true) !== 0x02014b50) + fail(); + const flags = view.getUint16(at + 8, true); + const method = view.getUint16(at + 10, true); + const compressed = view.getUint32(at + 20, true); + const size = view.getUint32(at + 24, true); + const nameLength = view.getUint16(at + 28, true); + const extraLength = view.getUint16(at + 30, true); + const commentLength = view.getUint16(at + 32, true); + const local = view.getUint32(at + 42, true); + const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength)); + at += 46 + nameLength + extraLength + commentLength; + if (name.endsWith('/')) continue; + if (flags & 0x1) fail('That KMZ is encrypted.'); + if (compressed === 0xffffffff || size === 0xffffffff) + fail('That KMZ uses a zip format this import does not read.'); + if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50) + fail(); + const start = + local + + 30 + + view.getUint16(local + 26, true) + + view.getUint16(local + 28, true); + if (start + compressed > bytes.length) fail(); + const data = bytes.subarray(start, start + compressed); + let contents; + if (method === 0) contents = data; + else if (method === 8) + contents = await inflateRaw(data, maxInflatedBytes - inflated); + else fail('That KMZ uses a compression method this import does not read.'); + inflated += contents.length; + if (inflated > maxInflatedBytes) + fail('That KMZ unpacks to more than the import limit.'); + entries.set(name, contents); + } + return entries; +} + +/** + * The KML document inside a KMZ: `doc.kml` when present, else the first + * `.kml` entry nearest the archive root. + * @param {Map} entries + * @returns {string|null} Entry path. + */ +export function kmzMainDocument(entries) { + if (entries.has('doc.kml')) return 'doc.kml'; + const kml = [...entries.keys()] + .filter((name) => /\.kml$/i.test(name)) + .sort( + (a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b), + ); + return kml[0] ?? null; +} + +/** + * Resolve a KMZ-relative href to an entry path, or null. + * @param {string} href As written in the KML. + * @param {string} base The KML entry's path. + * @param {Map} entries + * @returns {string|null} + */ +export function resolveKmzPath(href, base, entries) { + const raw = String(href || '').trim(); + if (!raw || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//')) + return null; + let decoded; + try { + decoded = decodeURIComponent(raw.split(/[?#]/)[0]); + } catch { + return null; + } + const parts = base.split('/').slice(0, -1); + for (const part of decoded.replace(/\\/g, '/').split('/')) { + if (!part || part === '.') continue; + if (part === '..') parts.pop(); + else parts.push(part); + } + const path = parts.join('/'); + return entries.has(path) ? path : null; +} + +/** MIME type for a KMZ image entry, or null when it is not an image we serve. */ +export function kmzImageType(path) { + const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase(); + return KMZ_IMAGE_TYPES[ext] ?? null; +} + +const elementsNamed = (doc, localName) => + [...doc.getElementsByTagName('*')].filter((el) => el.localName === localName); + +/** + * Strip everything from a parsed KML document that would make the page reach + * outside the file. `resolveLocal(href)` returns a URL for an image packed in + * the same KMZ (or null); every other href is removed, so icons fall back to + * Cesium's default pin and a ground overlay without a local image is dropped. + * @param {Document} doc + * @param {(href: string) => string|null} [resolveLocal] + * @returns {{removed: number}} How many elements were taken out. + */ +export function sanitizeKmlDocument(doc, resolveLocal = () => null) { + let removed = 0; + const drop = (el) => { + if (el.parentNode) { + el.parentNode.removeChild(el); + removed += 1; + } + }; + for (const name of KML_REMOVED_ELEMENTS) + elementsNamed(doc, name).forEach(drop); + for (const href of elementsNamed(doc, 'href')) { + const value = (href.textContent || '').trim(); + const local = resolveLocal(value); + if (local) href.textContent = local; + else if (/^data:image\/(png|jpeg|gif|webp|bmp);/i.test(value)) continue; + else drop(href); + } + for (const overlay of elementsNamed(doc, 'GroundOverlay')) { + if (!elementsNamed(overlay, 'href').length) drop(overlay); + } + for (const styleUrl of elementsNamed(doc, 'styleUrl')) { + if (!(styleUrl.textContent || '').trim().startsWith('#')) drop(styleUrl); + } + // Placemark balloons are HTML; the app shows no info box, and keeping their + // /]]> -122.47,37.77,0 + QA palette pin#palette + -122.48,37.771,0 + QA moving + 2019-01-01T00:00:00Z2019-01-01T01:00:00Z + -122.49 37.767 0-122.475 37.772 0 QA remote stylehttps://${TRAP_HOST}/styles.kml#x -122.50,37.766 -122.46,37.772 https://${TRAP_HOST}/overlay.png @@ -67,8 +87,10 @@ const kml = (iconHref) => ` const gpx = ` QA waypoint - QA track - + QA track<img src="https://${TRAP_HOST}/gpx.png"> + + + `; // 1×1 PNG. @@ -149,8 +171,12 @@ const page = await browser.newPage(); const errors = []; const trapped = []; page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +// Anything a file names, and the icon assets Cesium would load on a file's +// behalf (KML palette icons from maps.google.com, GeoJSON maki markers). +const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/']; page.on('request', (request) => { - if (request.url().includes(TRAP_HOST)) trapped.push(request.url()); + if (FILE_FETCH.some((needle) => request.url().includes(needle))) + trapped.push(request.url()); }); let failures = 0; function check(name, passed, detail = '') { @@ -250,6 +276,23 @@ try { )), ); const cyanBefore = await cyanPixels(); + const clock = () => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.clock; + const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0); + return { + start: iso(c.startTime), + stop: iso(c.stopTime), + range: c.clockRange, + step: c.clockStep, + multiplier: c.multiplier, + animate: c.shouldAnimate, + // Wall-clock drift aside, the current time must not jump to 2018/2019. + year: Number(iso(c.currentTime).slice(0, 4)), + }; + }); + const clockBefore = await clock(); + const sourcesBefore = before.dataSources; // Picked through the real . const input = await page.$('#geo-import-input'); @@ -286,6 +329,65 @@ try { /Added 4 files\..*left out/.test(after.hint), after.hint, ); + const clockAfter = await clock(); + check( + "dated KML and GPX leave the app's clock alone", + JSON.stringify(clockAfter) === JSON.stringify(clockBefore) && + (await page.evaluate( + () => + window.__godsEyeView.viewer.clockTrackedDataSource?.clock === + undefined, + )), + `${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`, + ); + const inside = await page.evaluate((from) => { + const viewer = window.__godsEyeView.viewer; + const now = viewer.clock.currentTime; + const sources = []; + for (let i = from; i < viewer.dataSources.length; i++) + sources.push(viewer.dataSources.get(i)); + const entities = sources.flatMap((ds) => ds.entities.values); + const named = (name) => entities.find((e) => e.name === name); + const gpx = sources[3].entities.values; + return { + sources: sources.length, + clocks: sources.filter((ds) => ds.clock !== undefined).length, + described: entities.filter((e) => e.description !== undefined).length, + labelled: entities.filter((e) => e.label !== undefined).length, + withProperties: entities.filter((e) => e.properties !== undefined).length, + withKml: entities.filter((e) => e.kml !== undefined).length, + hidden: entities.filter((e) => !e.isAvailable(now)).length, + datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)), + movingIsLine: Boolean( + named('QA moving')?.polyline && !named('QA moving')?.position, + ), + gpxLines: gpx.filter((e) => e.polyline).length, + gpxPoints: gpx.filter((e) => e.billboard || e.point).length, + }; + }, sourcesBefore); + check( + 'no imported entity keeps a description, label, properties or KML data', + inside.sources === 4 && + inside.described + + inside.labelled + + inside.withProperties + + inside.withKml === + 0, + JSON.stringify(inside), + ); + check( + 'no imported source has a clock, and dated features draw now', + inside.clocks === 0 && + inside.hidden === 0 && + inside.datedPinNow && + inside.movingIsLine, + JSON.stringify(inside), + ); + check( + 'the GPX track keeps its two segments apart', + inside.gpxLines === 2 && inside.gpxPoints === 1, + JSON.stringify(inside), + ); await page.evaluate((view) => { const viewer = window.__godsEyeView.viewer; viewer.camera.cancelFlight?.(); @@ -325,6 +427,26 @@ try { (await cyanPixels()) > cyanHidden * 4, ); + // Keyboard: focus stays on the same control when the list is rebuilt. + const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`; + await page.focus(toggleSelector); + await page.keyboard.press('Enter'); + const focusAfterHide = await page.evaluate( + (sel) => document.activeElement === document.querySelector(sel), + toggleSelector, + ); + await page.keyboard.press('Enter'); + const focusAfterShow = await page.evaluate( + (sel) => + document.activeElement === document.querySelector(sel) && + document.activeElement.getAttribute('aria-pressed') === 'true', + toggleSelector, + ); + check( + 'keyboard focus stays on the toggle through hide and show', + focusAfterHide && focusAfterShow, + ); + // Dropped onto the globe, as a person drags from their file manager. await page.evaluate((text) => { const container = window.__godsEyeView.viewer.container; @@ -387,7 +509,7 @@ try { ); await wait(1500); check( - 'nothing named inside the KML/KMZ was ever requested', + 'nothing named inside a file, and no icon asset for one, was ever requested', trapped.length === 0, trapped.join(' '), ); diff --git a/src/app/tools.js b/src/app/tools.js index 76f8e7fe6..7acc4939e 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -60,7 +60,10 @@ export function createApplicationTools({ // DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its // own data source. They live for the page session, so the application // lifetime removes them and the drop listeners on the viewer container. - const geoFileImport = initGeoFileImport({ viewer }); + const geoFileImport = initGeoFileImport({ + viewer, + navigation: styleManager, + }); defer(() => geoFileImport?.destroy()); // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // panel lives on the right rail, so both belong to the application diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index ee38b7fa1..53d954957 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -8,6 +8,23 @@ * before Cesium sees the document (`sanitizeKmlDocument`). Images packed * inside a KMZ are served to Cesium from blob URLs the caller revokes. * + * What the file says about itself stays inert: descriptions, balloons, + * extended data and GeoJSON properties are dropped from every entity, names + * are kept only as short plain text, and no native Cesium label is drawn + * (`scrubImportedEntities`). GeoJSON and GPX draw in the row's color: + * simplestyle properties are dropped, including `marker-symbol`, so no icon + * asset is loaded on the file's behalf. KML and KMZ keep their own styles. + * + * An import never moves the app's shared clock: dated KML (TimeSpan, + * TimeStamp, gx:Track) is drawn for all time, and the data source exposes no + * clock for the viewer to adopt (`detachImportedTime`). GPX is read here + * rather than by Cesium (`gpxToGeoJson`), keeping each track segment as its + * own line and leaving times out. + * + * Size is checked before Cesium builds anything (`countGeoJsonGeometry`, + * `countKmlGeometry`): a file with too many features or vertices is refused + * up front instead of after the entities exist. + * * The bounded pieces — format detection, the KMZ (zip) reader, the size and * count limits — are plain functions so they run under `node --test`; the * Cesium loaders need a browser DOM and are exercised by @@ -23,6 +40,10 @@ export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; export const MAX_IMPORTED_FILES = 12; /** Most entities one file may create before it is refused. */ export const MAX_IMPORT_ENTITIES = 20_000; +/** Most vertices (points of every line, ring and marker) in one file. */ +export const MAX_IMPORT_POSITIONS = 250_000; +/** Longest plain-text name kept from a file's features. */ +export const MAX_IMPORT_NAME_LENGTH = 120; /** What the file picker offers. */ export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx'; @@ -127,6 +148,112 @@ export function parseGeoJsonText(text) { return value; } +/** + * Count features and vertices in a parsed GeoJSON object without recursion + * limits or array spreading, stopping as soon as a limit is passed. + * @param {object} geojson + * @param {{maxFeatures?: number, maxPositions?: number}} [limits] + * @returns {{features: number, positions: number, over: ''|'features'|'positions'}} + */ +export function countGeoJsonGeometry( + geojson, + { + maxFeatures = MAX_IMPORT_ENTITIES, + maxPositions = MAX_IMPORT_POSITIONS, + } = {}, +) { + let features = 0; + let positions = 0; + const stack = [geojson]; + while (stack.length) { + const node = stack.pop(); + if (!node || typeof node !== 'object') continue; + if (node.type === 'FeatureCollection') { + const list = Array.isArray(node.features) ? node.features : []; + for (let i = 0; i < list.length; i++) stack.push(list[i]); + continue; + } + if (node.type === 'Feature') { + features += 1; + if (features > maxFeatures) + return { features, positions, over: 'features' }; + stack.push(node.geometry); + continue; + } + if (node.type === 'GeometryCollection') { + const list = Array.isArray(node.geometries) ? node.geometries : []; + for (let i = 0; i < list.length; i++) stack.push(list[i]); + continue; + } + if (GEOJSON_TYPES.has(node.type)) { + // A bare geometry is one feature: Cesium makes one entity for it. + if (node === geojson) features += 1; + // Walk the coordinate arrays iteratively: a position is an array whose + // first item is a number. + const coords = [node.coordinates]; + while (coords.length) { + const c = coords.pop(); + if (!Array.isArray(c)) continue; + if (typeof c[0] === 'number') { + positions += 1; + if (positions > maxPositions) + return { features, positions, over: 'positions' }; + } else for (let i = 0; i < c.length; i++) coords.push(c[i]); + } + } + } + return { features, positions, over: '' }; +} + +/** The refusal for a file over a size limit, in the person's terms. */ +function overLimitError({ over, features, positions }) { + if (over === 'features') + return new GeoImportError( + `That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`, + ); + return new GeoImportError( + `That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`, + ); +} + +// simplestyle-spec keys Cesium honors. `marker-symbol` would load a maki icon +// asset on the file's behalf; the colors would override the row's color. +const GEOJSON_STYLE_KEYS = Object.freeze([ + 'marker-symbol', + 'marker-color', + 'marker-size', + 'stroke', + 'stroke-opacity', + 'stroke-width', + 'fill', + 'fill-opacity', +]); + +/** + * Drop GeoJSON simplestyle properties, so a GeoJSON file draws in its row's + * color like a GPX file and no icon is fetched for it. Mutates and returns + * the object. + * @param {object} geojson + * @returns {object} + */ +export function stripGeoJsonStyleProperties(geojson) { + const stack = [geojson]; + while (stack.length) { + const node = stack.pop(); + if (!node || typeof node !== 'object') continue; + if (node.type === 'FeatureCollection' && Array.isArray(node.features)) + for (let i = 0; i < node.features.length; i++) + stack.push(node.features[i]); + else if ( + node.type === 'Feature' && + node.properties && + typeof node.properties === 'object' + ) + for (const key of GEOJSON_STYLE_KEYS) delete node.properties[key]; + } + return geojson; +} + /** Inflate raw-deflate bytes with the platform's stream, stopping at `maxBytes`. */ async function inflateRaw(bytes, maxBytes) { const stream = new Blob([bytes]) @@ -326,6 +453,253 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) { return { removed }; } +/** Child elements of `el` with this local name (any namespace). */ +const childrenNamed = (el, localName) => { + const out = []; + const kids = el?.childNodes || []; + for (let i = 0; i < kids.length; i++) + if (kids[i].nodeType === 1 && kids[i].localName === localName) + out.push(kids[i]); + return out; +}; +const childText = (el, localName) => + (childrenNamed(el, localName)[0]?.textContent || '').trim(); + +/** + * Count placemarks and vertices in a parsed KML document before Cesium reads + * it: every `` tuple and every `gx:coord`. + * @param {Document} doc + * @returns {{features: number, positions: number, over: ''|'features'|'positions'}} + */ +export function countKmlGeometry( + doc, + { + maxFeatures = MAX_IMPORT_ENTITIES, + maxPositions = MAX_IMPORT_POSITIONS, + } = {}, +) { + let features = 0; + let positions = 0; + for (const el of doc.getElementsByTagName('*')) { + const name = el.localName; + if (name === 'Placemark') { + features += 1; + if (features > maxFeatures) + return { features, positions, over: 'features' }; + } else if (name === 'coordinates') { + const text = el.textContent || ''; + // Tuples are separated by whitespace; count runs without splitting + // a large string into a large array. + let inTuple = false; + for (let i = 0; i < text.length; i++) { + const space = text.charCodeAt(i) <= 32; + if (!space && !inTuple) { + positions += 1; + if (positions > maxPositions) + return { features, positions, over: 'positions' }; + } + inTuple = !space; + } + } else if (name === 'coord') { + positions += 1; + if (positions > maxPositions) + return { features, positions, over: 'positions' }; + } + } + return { features, positions, over: '' }; +} + +/** + * Read a parsed GPX document into GeoJSON: waypoints become points, each + * route a line and each track one MultiLineString with a line per `` + * (segment breaks are gaps in the recording and stay gaps). Only names are + * kept, and no times, so a dated track draws without a clock. Throws + * `GeoImportError` past the size limits. + * @param {Document} doc + * @returns {object} A FeatureCollection. + */ +export function gpxToGeoJson( + doc, + { + maxFeatures = MAX_IMPORT_ENTITIES, + maxPositions = MAX_IMPORT_POSITIONS, + } = {}, +) { + const root = doc.documentElement; + if (root?.localName !== 'gpx') + throw new GeoImportError('That GPX file has no document.'); + const features = []; + let positions = 0; + const point = (el) => { + const lat = Number(el.getAttribute('lat')); + const lon = Number(el.getAttribute('lon')); + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + return null; + positions += 1; + if (positions > maxPositions) + throw overLimitError({ + over: 'positions', + features: features.length, + positions, + }); + return [lon, lat]; + }; + const add = (name, geometry) => { + if (features.length >= maxFeatures) + throw overLimitError({ + over: 'features', + features: features.length + 1, + positions, + }); + features.push({ + type: 'Feature', + properties: name ? { name } : {}, + geometry, + }); + }; + for (const wpt of childrenNamed(root, 'wpt')) { + const at = point(wpt); + if (at) add(childText(wpt, 'name'), { type: 'Point', coordinates: at }); + } + for (const rte of childrenNamed(root, 'rte')) { + const line = []; + for (const pt of childrenNamed(rte, 'rtept')) { + const at = point(pt); + if (at) line.push(at); + } + if (line.length >= 2) + add(childText(rte, 'name'), { type: 'LineString', coordinates: line }); + } + for (const trk of childrenNamed(root, 'trk')) { + const lines = []; + for (const seg of childrenNamed(trk, 'trkseg')) { + const line = []; + for (const pt of childrenNamed(seg, 'trkpt')) { + const at = point(pt); + if (at) line.push(at); + } + if (line.length >= 2) lines.push(line); + } + if (lines.length) + add(childText(trk, 'name'), { + type: 'MultiLineString', + coordinates: lines, + }); + } + return { type: 'FeatureCollection', features }; +} + +/** A feature name as short plain text (no control characters), or undefined. */ +export function importFeatureName(value) { + const text = String(value ?? '') + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!text) return undefined; + return text.length > MAX_IMPORT_NAME_LENGTH + ? `${text.slice(0, MAX_IMPORT_NAME_LENGTH - 1)}…` + : text; +} + +/** + * Make every entity of an imported data source inert: no description, + * balloon, extended data or properties, no native label, and a name that is + * short plain text. Returns how many entities were touched. + * @param {{entities: {values: object[]}}} dataSource + * @returns {number} + */ +export function scrubImportedEntities(dataSource) { + const list = dataSource?.entities?.values || []; + for (let i = 0; i < list.length; i++) { + const entity = list[i]; + let name; + try { + name = importFeatureName( + typeof entity.name === 'string' ? entity.name : undefined, + ); + } catch { + name = undefined; + } + entity.name = name; + entity.description = undefined; + entity.label = undefined; + entity.properties = undefined; + if (entity.kml && typeof entity.kml === 'object') entity.kml = undefined; + } + return list.length; +} + +const TRACK_SAMPLES = 2000; + +/** + * Take time out of an imported data source so adding it cannot move the app's + * shared clock: the data source reports no clock (the viewer adopts the clock + * of every added source that has one), every entity is available for all + * time, and a time-dynamic position (a KML gx:Track) is drawn as the line it + * travels instead of a moving marker. + * @param {object} dataSource + * @param {{Cesium?: object, color?: object}} [options] + * @returns {object} The same data source. + */ +export function detachImportedTime( + dataSource, + { Cesium: C = Cesium, color } = {}, +) { + Object.defineProperty(dataSource, 'clock', { + configurable: true, + enumerable: false, + get: () => undefined, + set: () => {}, + }); + const list = dataSource?.entities?.values || []; + for (let i = 0; i < list.length; i++) { + const entity = list[i]; + const span = entity.availability; + const position = entity.position; + if (position && position.isConstant === false) { + const start = span?.start; + const stop = span?.stop; + const line = []; + if (start && stop && C.JulianDate.lessThan(start, stop)) { + const seconds = C.JulianDate.secondsDifference(stop, start); + for (let n = 0; n <= TRACK_SAMPLES; n++) { + const t = C.JulianDate.addSeconds( + start, + (seconds * n) / TRACK_SAMPLES, + new C.JulianDate(), + ); + const at = position.getValue(t); + if (at) line.push(at); + } + } + entity.path = undefined; + entity.model = undefined; + if (line.length >= 2 && !entity.polyline) { + entity.billboard = undefined; + entity.point = undefined; + entity.position = undefined; + entity.polyline = new C.PolylineGraphics({ + positions: line, + clampToGround: true, + width: 3, + material: color || C.Color.WHITE, + }); + } else { + entity.position = line.length + ? new C.ConstantPositionProperty(line[0]) + : undefined; + } + } + entity.availability = undefined; + } + return dataSource; +} + function parseXml(text, what) { const doc = new DOMParser().parseFromString( String(text).replace(/^/, ''), @@ -364,22 +738,27 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) { let dataSource; let removed = 0; try { - if (format === 'geojson') { - dataSource = await Cesium.GeoJsonDataSource.load( - parseGeoJsonText(new TextDecoder().decode(bytes)), - { - clampToGround: true, - stroke, - fill: stroke.withAlpha(0.25), - strokeWidth: 3, - markerColor: stroke, - }, - ); - } else if (format === 'gpx') { - dataSource = await Cesium.GpxDataSource.load( - parseXml(new TextDecoder().decode(bytes), 'GPX'), - { clampToGround: true, trackColor: stroke, routeColor: stroke }, - ); + if (format === 'geojson' || format === 'gpx') { + let geojson; + if (format === 'gpx') { + geojson = gpxToGeoJson( + parseXml(new TextDecoder().decode(bytes), 'GPX'), + ); + } else { + geojson = parseGeoJsonText(new TextDecoder().decode(bytes)); + const size = countGeoJsonGeometry(geojson); + if (size.over) throw overLimitError(size); + stripGeoJsonStyleProperties(geojson); + } + dataSource = await Cesium.GeoJsonDataSource.load(geojson, { + clampToGround: true, + stroke, + fill: stroke.withAlpha(0.25), + strokeWidth: 3, + markerColor: stroke, + // No HTML description table is built from the file's properties. + describe: () => undefined, + }); } else { let text; let resolveLocal = () => null; @@ -402,6 +781,8 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) { text = new TextDecoder().decode(bytes); } const doc = parseXml(text, format.toUpperCase()); + const size = countKmlGeometry(doc); + if (size.over) throw overLimitError(size); removed = sanitizeKmlDocument(doc, resolveLocal).removed; dataSource = await Cesium.KmlDataSource.load(doc, { camera: viewer.scene.camera, @@ -427,6 +808,11 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) { revoke(); throw new GeoImportError('That file has nothing to draw.'); } - // GpxDataSource's `name` is read-only, so the display name travels beside it. + scrubImportedEntities(dataSource); + // Only KML has time-dynamic positions (GPX is read without times); its + // track line is drawn in KML's default white, like its other unstyled lines. + detachImportedTime(dataSource); + // The display name travels beside the data source (the file name, not + // anything the document says about itself). return { dataSource, name, format, entityCount, removed, revoke }; } diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index 522af2550..c589438fc 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1,8 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { deflateRawSync } from 'node:zlib'; +import * as Cesium from 'cesium'; import { GeoImportError, + MAX_IMPORT_POSITIONS, + countGeoJsonGeometry, + countKmlGeometry, + detachImportedTime, + gpxToGeoJson, + importFeatureName, + scrubImportedEntities, + stripGeoJsonStyleProperties, detectGeoFileFormat, importDisplayName, kmzImageType, @@ -213,3 +222,283 @@ test('KMZ hrefs resolve only to entries inside the archive', () => { assert.equal(kmzImageType('files/a.PNG'), 'image/png'); assert.equal(kmzImageType('files/a.svg'), null); }); + +/* ── a tiny element tree with the DOM surface the readers use ─────────── */ +function xmlEl(localName, attrs = {}, kids = []) { + const el = { + nodeType: 1, + localName, + childNodes: [], + getAttribute: (n) => (n in attrs ? String(attrs[n]) : null), + }; + for (const k of typeof kids === 'string' ? [] : kids) el.childNodes.push(k); + const text = typeof kids === 'string' ? kids : null; + Object.defineProperty(el, 'textContent', { + get: () => text ?? el.childNodes.map((c) => c.textContent).join(''), + }); + return el; +} +const xmlDoc = (root) => ({ + documentElement: root, + getElementsByTagName(name) { + assert.equal(name, '*'); + const out = []; + const walk = (el) => { + out.push(el); + el.childNodes.forEach(walk); + }; + walk(root); + return out; + }, +}); +const trkpt = (lat, lon, time) => + xmlEl('trkpt', { lat, lon }, time ? [xmlEl('time', {}, time)] : []); + +test('GPX keeps each track segment as its own line, names as text, no times', () => { + const doc = xmlDoc( + xmlEl('gpx', {}, [ + xmlEl('wpt', { lat: 37.77, lon: -122.49 }, [xmlEl('name', {}, 'Camp')]), + xmlEl('wpt', { lat: 99, lon: 0 }), // out of range: skipped + xmlEl('rte', {}, [ + xmlEl('name', {}, 'Route'), + xmlEl('rtept', { lat: 1, lon: 2 }), + xmlEl('rtept', { lat: 3, lon: 4 }), + ]), + xmlEl('trk', {}, [ + xmlEl('name', {}, 'Morning walk'), + xmlEl('trkseg', {}, [ + trkpt(10, 20, '2024-05-01T10:00:00Z'), + trkpt(10.1, 20.1, '2024-05-01T10:01:00Z'), + ]), + // A pause in the recording: a new segment, not a straight jump. + xmlEl('trkseg', {}, [ + trkpt(11, 21, '2024-05-01T11:00:00Z'), + trkpt(11.1, 21.1, '2024-05-01T11:01:00Z'), + trkpt(11.2, 21.2, '2024-05-01T11:02:00Z'), + ]), + xmlEl('trkseg', {}, [trkpt(12, 22)]), // one point: not a line + ]), + ]), + ); + const fc = gpxToGeoJson(doc); + assert.deepEqual( + fc.features.map((f) => [f.properties.name, f.geometry.type]), + [ + ['Camp', 'Point'], + ['Route', 'LineString'], + ['Morning walk', 'MultiLineString'], + ], + ); + assert.deepEqual(fc.features[2].geometry.coordinates, [ + [ + [20, 10], + [20.1, 10.1], + ], + [ + [21, 11], + [21.1, 11.1], + [21.2, 11.2], + ], + ]); + assert.doesNotMatch(JSON.stringify(fc), /2024|time/); + assert.throws(() => gpxToGeoJson(xmlDoc(xmlEl('kml'))), GeoImportError); +}); + +test('GPX past the point limit is refused while reading, before Cesium', () => { + const seg = xmlEl( + 'trkseg', + {}, + Array.from({ length: 30 }, (_, i) => trkpt(1 + i / 100, 2)), + ); + const doc = xmlDoc(xmlEl('gpx', {}, [xmlEl('trk', {}, [seg])])); + assert.throws( + () => gpxToGeoJson(doc, { maxPositions: 20 }), + (e) => e instanceof GeoImportError && /more than .* points/.test(e.message), + ); + assert.throws( + () => + gpxToGeoJson( + xmlDoc( + xmlEl('gpx', {}, [ + xmlEl('wpt', { lat: 1, lon: 1 }), + xmlEl('wpt', { lat: 2, lon: 2 }), + ]), + ), + { maxFeatures: 1 }, + ), + /more than .* features/, + ); +}); + +test('GeoJSON features and vertices are counted before loading, without recursion', () => { + const line = Array.from({ length: 50 }, (_, i) => [i / 10, 1]); + const fc = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', geometry: { type: 'LineString', coordinates: line } }, + { + type: 'Feature', + geometry: { + type: 'GeometryCollection', + geometries: [ + { type: 'Point', coordinates: [1, 2] }, + { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }, + ], + }, + }, + { type: 'Feature', geometry: null }, + ], + }; + assert.deepEqual(countGeoJsonGeometry(fc), { + features: 3, + positions: 55, + over: '', + }); + assert.equal( + countGeoJsonGeometry(fc, { maxPositions: 54 }).over, + 'positions', + ); + assert.equal(countGeoJsonGeometry(fc, { maxFeatures: 2 }).over, 'features'); + assert.deepEqual( + countGeoJsonGeometry({ type: 'Point', coordinates: [1, 2] }), + { + features: 1, + positions: 1, + over: '', + }, + ); + // A very long line is counted iteratively and stops at the limit. + const huge = { + type: 'LineString', + coordinates: Array.from({ length: MAX_IMPORT_POSITIONS + 5 }, () => [0, 0]), + }; + assert.equal(countGeoJsonGeometry(huge).over, 'positions'); +}); + +test('KML placemarks and coordinate tuples are counted before loading', () => { + const coords = (n) => + xmlEl( + 'coordinates', + {}, + Array.from({ length: n }, (_, i) => `${i},1,0`).join('\n '), + ); + const doc = xmlDoc( + xmlEl('kml', {}, [ + xmlEl('Placemark', {}, [xmlEl('LineString', {}, [coords(40)])]), + xmlEl('Placemark', {}, [ + xmlEl('Track', {}, [ + xmlEl('coord', {}, '1 2 0'), + xmlEl('coord', {}, '1 3 0'), + ]), + ]), + ]), + ); + assert.deepEqual(countKmlGeometry(doc), { + features: 2, + positions: 42, + over: '', + }); + assert.equal(countKmlGeometry(doc, { maxPositions: 41 }).over, 'positions'); + assert.equal(countKmlGeometry(doc, { maxFeatures: 1 }).over, 'features'); +}); + +test('simplestyle is dropped: no icon fetched, the row color is what draws', () => { + const fc = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + 'marker-symbol': 'rail', + 'marker-color': '#f00', + stroke: '#0f0', + 'stroke-width': 9, + fill: '#00f', + 'fill-opacity': 1, + name: 'x', + }, + geometry: null, + }, + { type: 'Feature', properties: null, geometry: null }, + ], + }; + stripGeoJsonStyleProperties(fc); + assert.deepEqual(fc.features[0].properties, { name: 'x' }); +}); + +test('imported entities keep only a short plain-text name: no description, label or properties', () => { + const ds = new Cesium.CustomDataSource('t'); + const e = ds.entities.add({ + name: 'Trail\u0007 head\n' + 'x'.repeat(300), + description: '', + label: { text: 'Trail head' }, + properties: { note: '' }, + position: Cesium.Cartesian3.fromDegrees(1, 2), + }); + e.kml = { extendedData: { note: { value: '