diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 8533e9710..3297c2e5d 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -42,7 +42,7 @@ They check build-time imports, not arbitrary runtime-generated module URLs. Keep runtime module discovery out of these exports. When extracting another component, add its ownership and consumer tests together. Node services must use separate entry points and their own checks when they become reusable; importing -them into a browser component is not supported. No provider service is exported yet. +them into a browser component is not supported. `gods-eye-view/application` owns construction order, startup state, cancellation and disposal of caller-supplied components. Its only owned module is @@ -100,3 +100,20 @@ socket/watchdog on server close and re-reads configuration after restart. The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft records without importing Node middleware or a renderer. Browser layer/controller separation is outside this server extraction. + +## Place-search and routing providers + +`gods-eye-view/server/providers/places` exports the Google nearby-place and +text-search plugin, OSRM route registration, and their shared Node helpers. +The existing Overpass plugin still mounts `/api/route` in its original order. +Google credentials are resolved on each request; the default uses the existing +server-key precedence, and callers may supply `resolveApiKey`. + +`gods-eye-view/sources/places` exports portable response projections for Google +place results and OSRM route results/profile aliases. These functions own no +credentials, requests, caches, environment loading, or rendering. Callers retain +input validation and upstream-response acceptance. The boundary check builds +this entry independently and rejects Node imports. + +The browser's direct geocoding, annotation rendering and full Overpass query +service remain in their existing modules. diff --git a/package.json b/package.json index c8b6c6e87..89a30f4b3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", "author": "Bilawal Sidhu", @@ -73,6 +73,10 @@ "./server/providers/live": { "node": "./server/providers/live.js" }, - "./sources/adsb-lol": "./src/data/adsbLolFallback.js" + "./sources/adsb-lol": "./src/data/adsbLolFallback.js", + "./server/providers/places": { + "node": "./server/providers/places.js" + }, + "./sources/places": "./src/data/placeProviderPayloads.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3653188c5..7177bfd0c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,27 +10,47 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite"], - "modules": ["build/vite.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite" + ], + "modules": [ + "build/vite.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -45,11 +65,44 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], + "external": [] + }, + "place-providers": { + "runtime": "node", + "exports": [ + "./server/providers/places" + ], + "modules": [ + "server/providers/places.js", + "server/providers/places/google.js", + "server/providers/places/google-key.js", + "server/providers/places/routes.js", + "server/providers/common/rate-limit.js", + "server/providers/common/geo.js", + "server/providers/common/http.js", + "scripts/google-server-key.mjs", + "src/data/placeProviderPayloads.js" + ], + "external": [] + }, + "place-payloads": { + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] } } diff --git a/server/providers/common/geo.js b/server/providers/common/geo.js new file mode 100644 index 000000000..1adf697e1 --- /dev/null +++ b/server/providers/common/geo.js @@ -0,0 +1,17 @@ +/** + * Haversine great-circle distance between two WGS-84 points. + * + * @param {number} lat1 - Latitude of point A (degrees). + * @param {number} lon1 - Longitude of point A (degrees). + * @param {number} lat2 - Latitude of point B (degrees). + * @param {number} lon2 - Longitude of point B (degrees). + * @returns {number} Distance in kilometers. + */ +export function haversineKm(lat1, lon1, lat2, lon2) { + const toRad = (value) => value * Math.PI / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/server/providers/common/rate-limit.js b/server/providers/common/rate-limit.js new file mode 100644 index 000000000..85103f04c --- /dev/null +++ b/server/providers/common/rate-limit.js @@ -0,0 +1,60 @@ +/** + * Minimal fixed-window per-key rate limiter for the dev proxies. Not a hard + * security boundary (dev-only), just a backstop so a runaway client can't hammer + * the public Overpass / OSRM mirrors or exhaust this process. + */ +const RATE_LIMITER_MAX_KEYS = 2000; + +export function makeRateLimiter({ windowMs, max, globalMax }) { + const hits = new Map(); // key -> number[] (timestamps within window) + let globalTimes = []; // all hits in window, for the global backstop + return function allow(key) { + const now = Date.now(); + globalTimes = globalTimes.filter((t) => now - t < windowMs); + if (globalMax && globalTimes.length >= globalMax) return false; // global backstop + const recent = (hits.get(key) || []).filter((t) => now - t < windowMs); + if (recent.length >= max) { hits.set(key, recent); return false; } + recent.push(now); + hits.set(key, recent); + globalTimes.push(now); + // Hard key cap so a key-rotating caller can't grow the map without bound. + if (hits.size > RATE_LIMITER_MAX_KEYS) { + const oldest = hits.keys().next().value; + if (oldest !== undefined) hits.delete(oldest); + } + if (hits.size > 256) { + for (const [k, v] of hits) { + if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k); + } + } + return true; + }; +} + +/** + * Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google). + * DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this + * returns `null` and the caller skips the check entirely — a runtime no-op that + * preserves the original behavior. Only a positive integer N enables a fixed + * 60s window of N requests/IP (built lazily once, then reused so its per-IP + * window state persists across requests). The global backstop is set to a + * generous multiple of the per-IP cap so a single host can't starve the rest. + * + * @param {string|undefined} envValue - Raw env value (requests/min/IP). + * @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited. + */ +export function makeOptInRateLimiter(envValue) { + const max = Number(envValue); + if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited + return makeRateLimiter({ windowMs: 60_000, max: Math.floor(max), globalMax: Math.floor(max) * 20 }); +} + +/** + * Client key for rate limiting. Uses the real socket peer address only — we do + * NOT trust X-Forwarded-For (client-controlled; a rotating value would mint fresh + * quota and grow the limiter map). This is a localhost dev proxy, so the socket + * address is the real client. + */ +export function clientKey(req) { + return String(req.socket?.remoteAddress || 'local'); +} diff --git a/server/providers/local.js b/server/providers/local.js index db4244d82..89640864a 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -24,7 +24,8 @@ * @module server/providers/local */ -import { resolveGoogleServerKey } from '../../scripts/google-server-key.mjs'; +import { googlePlacesContextProxy, googleServerApiKey, keylessGooglePlacesResponse, installRouteMiddleware, makeRateLimiter, makeOptInRateLimiter, clientKey, haversineKm } from './places.js'; +export { googlePlacesContextProxy, googleServerApiKey, keylessGooglePlacesResponse }; import { openSkyProxy, adsbLolFallbackAnchor } from './aircraft/opensky.js'; import { adsbLolProxy } from './aircraft/adsb-lol.js'; @@ -339,9 +340,6 @@ async function readStaleOverpass(cacheKey) { const cached = _overpassCache.get(cacheKey); return overpassPayloadIsData(cached) ? cached : readOverpassDisk(cacheKey, Infinity); } -/** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */ -const ROUTE_CACHE_MS = 600000; -const _routeCache = new Map(); // --- Abuse guards shared by the Overpass + route proxies -------------------- /** Max accepted POST body for the Overpass proxy (Overpass QL queries are tiny). */ @@ -370,81 +368,19 @@ const OVERPASS_SIMPLIFY_TOLERANCE_DEG = 0.0004; /** Max concurrent in-flight upstream Overpass fetches across all distinct queries. */ const OVERPASS_MAX_CONCURRENT = 6; let _overpassConcurrent = 0; -/** Hard cap on the OSRM route response we will buffer. */ -const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB -/** Reject routes whose straight-line spans are obviously abusive (km). */ -const ROUTE_MAX_LEG_KM = 600; -const ROUTE_MAX_TOTAL_KM = 2500; - -/** - * Minimal fixed-window per-key rate limiter for the dev proxies. Not a hard - * security boundary (dev-only), just a backstop so a runaway client can't hammer - * the public Overpass / OSRM mirrors or exhaust this process. - */ -const RATE_LIMITER_MAX_KEYS = 2000; -function makeRateLimiter({ windowMs, max, globalMax }) { - const hits = new Map(); // key -> number[] (timestamps within window) - let globalTimes = []; // all hits in window, for the global backstop - return function allow(key) { - const now = Date.now(); - globalTimes = globalTimes.filter((t) => now - t < windowMs); - if (globalMax && globalTimes.length >= globalMax) return false; // global backstop - const recent = (hits.get(key) || []).filter((t) => now - t < windowMs); - if (recent.length >= max) { hits.set(key, recent); return false; } - recent.push(now); - hits.set(key, recent); - globalTimes.push(now); - // Hard key cap so a key-rotating caller can't grow the map without bound. - if (hits.size > RATE_LIMITER_MAX_KEYS) { - const oldest = hits.keys().next().value; - if (oldest !== undefined) hits.delete(oldest); - } - if (hits.size > 256) { - for (const [k, v] of hits) { - if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k); - } - } - return true; - }; -} const _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 }); const _militaryInstallationsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 }); -const _routeRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 200 }); - -/** - * Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google). - * DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this - * returns `null` and the caller skips the check entirely — a runtime no-op that - * preserves the original behavior. Only a positive integer N enables a fixed - * 60s window of N requests/IP (built lazily once, then reused so its per-IP - * window state persists across requests). The global backstop is set to a - * generous multiple of the per-IP cap so a single host can't starve the rest. - * - * @param {string|undefined} envValue - Raw env value (requests/min/IP). - * @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited. - */ -function makeOptInRateLimiter(envValue) { - const max = Number(envValue); - if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited - return makeRateLimiter({ windowMs: 60_000, max: Math.floor(max), globalMax: Math.floor(max) * 20 }); -} // Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later // (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading // process.env here at import time would always see them unset and silently stay unlimited even when // configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env; // the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default). -let _openAiRateLimiter; // undefined = not built yet; null = unlimited; fn = active limiter -let _googleRateLimiter; +let _openAiRateLimiter; /** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */ function openAiRateLimiter() { if (_openAiRateLimiter === undefined) _openAiRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_OPENAI_PER_MIN); return _openAiRateLimiter; } -/** Google cost endpoint (nearby-places). Null = unlimited (default). */ -function googleRateLimiter() { - if (_googleRateLimiter === undefined) _googleRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_GOOGLE_PER_MIN); - return _googleRateLimiter; -} /** * Apply an opt-in limiter to a request, writing a 429 when over the cap. @@ -466,16 +402,6 @@ function enforceOptInRateLimit(limiter, req, res) { return false; } -/** - * Client key for rate limiting. Uses the real socket peer address only — we do - * NOT trust X-Forwarded-For (client-controlled; a rotating value would mint fresh - * quota and grow the limiter map). This is a localhost dev proxy, so the socket - * address is the real client. - */ -function clientKey(req) { - return String(req.socket?.remoteAddress || 'local'); -} - /** Server-side timeout ceiling (seconds) we allow inside an Overpass QL query. */ const OVERPASS_MAX_QL_TIMEOUT = 30; /** Max `around:` radius (m) — every app caller uses <= 1800 m. */ @@ -2365,97 +2291,7 @@ function overpassProxy() { } }); - // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike). - // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...] - server.middlewares.use('/api/route', async (req, res) => { - const fail = (msg) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: false, error: msg })); - }; - try { - if (!_routeRateLimiter(clientKey(req))) { - res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' }); - res.end(JSON.stringify({ ok: false, error: 'rate limited' })); - return; - } - const url = new URL(req.url, 'http://localhost'); - const raw = (url.searchParams.get('profile') || 'foot').toLowerCase(); - const profile = (raw === 'car' || raw === 'driving') ? 'car' - : (raw === 'bike' || raw === 'cycling' || raw === 'bicycle') ? 'bike' - : (raw === 'foot' || raw === 'walking' || raw === 'walk') ? 'foot' - : null; - if (!profile) return fail('invalid profile'); - const osrmProfile = profile === 'car' ? 'driving' : profile; - const pairs = (url.searchParams.get('coords') || '').split(';').map((s) => s.trim()).filter(Boolean); - if (pairs.length < 2 || pairs.length > 12) return fail('need 2-12 coordinates'); - const clean = []; - const pts = []; - for (const pr of pairs) { - const parts = pr.split(','); - if (parts.length !== 2) return fail('invalid coordinate'); - const lon = Number(parts[0]); - const lat = Number(parts[1]); - if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { - return fail('invalid coordinate'); - } - clean.push(`${lon},${lat}`); - pts.push([lon, lat]); - } - // Reject obviously-abusive spans — a real walking/driving route is local, - // so a cross-continent request is either a bug or an attempt to drive - // heavy upstream OSRM work. - let totalKm = 0; - for (let i = 1; i < pts.length; i += 1) { - // pts are [lon, lat]; existing haversineKm takes (lat1, lon1, lat2, lon2). - const legKm = haversineKm(pts[i - 1][1], pts[i - 1][0], pts[i][1], pts[i][0]); - if (legKm > ROUTE_MAX_LEG_KM) return fail('route leg too long'); - totalKm += legKm; - } - if (totalKm > ROUTE_MAX_TOTAL_KM) return fail('route too long'); - const coords = clean.join(';'); - const cacheKey = `${profile}|${coords}`; - const now = Date.now(); - const cached = _routeCache.get(cacheKey); - if (cached && now - cached.cachedAt <= ROUTE_CACHE_MS) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(cached.payload)); - return; - } - const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 12000); - let osrm; - try { - const upstreamRes = await fetch(upstream, { - signal: controller.signal, - headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, - }); - if (!upstreamRes.ok) return fail('no route found'); - const ctype = upstreamRes.headers.get('content-type') || ''; - if (!ctype.includes('json')) return fail('no route found'); - const text = await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES); - osrm = JSON.parse(text); - } finally { - clearTimeout(timer); - } - const route = osrm?.routes?.[0]; - if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) return fail('no route found'); - const payload = { - ok: true, - profile, - distanceM: Math.round(route.distance), - durationS: Math.round(route.duration), - geometry: route.geometry.coordinates, - }; - _routeCache.set(cacheKey, { payload, cachedAt: now }); - if (_routeCache.size > 200) _routeCache.delete(_routeCache.keys().next().value); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); - } catch (e) { - console.error('[Route Proxy]', e?.message || e); - fail('route proxy error'); - } - }); + installRouteMiddleware(server.middlewares); }, }; } @@ -3017,24 +2853,6 @@ function rowArrayToObject(row, columns) { return record; } -/** - * Haversine great-circle distance between two WGS-84 points. - * - * @param {number} lat1 - Latitude of point A (degrees). - * @param {number} lon1 - Longitude of point A (degrees). - * @param {number} lat2 - Latitude of point B (degrees). - * @param {number} lon2 - Longitude of point B (degrees). - * @returns {number} Distance in kilometers. - */ -function haversineKm(lat1, lon1, lat2, lon2) { - const toRad = (value) => value * Math.PI / 180; - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat / 2) ** 2 - + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - /** * Distance-prioritizes cameras to a cap: keeps the maxCount cameras closest * to ANY of the given anchor points (min distance over anchors), tie-broken @@ -4255,308 +4073,6 @@ function readRequestBody(req, maxBytes = 1024 * 1024) { }); } -/** - * Optional Google place context is an empty capability when no key is present, - * not a server outage. Returning 200 keeps a deliberately keyless session out - * of the browser error console while preserving an explicit configured flag. - */ -export function keylessGooglePlacesResponse(apiKey) { - if (String(apiKey ?? '').trim()) return null; - return { - statusCode: 200, - payload: { configured: false, error: null, places: [] }, - }; -} - -/** - * 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. - */ -export function googleServerApiKey() { - return resolveGoogleServerKey(process.env); -} - -/** - * Vite plugin: nearby Google place labels for Realtime scene context. - * - * The Photorealistic 3D Tiles mesh does not expose rendered map labels as - * Cesium feature metadata. Nearby Search supplies the names around the actual - * screen-space target without exposing the Google API key in the request. - */ -export function googlePlacesContextProxy() { - function install(middlewares) { - middlewares.use('/api/google/nearby-places', async (req, res) => { - if (req.method !== 'GET') { - res.statusCode = 405; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'Method not allowed', places: [] })); - return; - } - - // Keyless place context has no provider cost, so it resolves before the - // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD - // summary route). - const apiKey = googleServerApiKey(); - const keyless = keylessGooglePlacesResponse(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; - } - - // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. - // Inlined (not the shared helper) so the 429 body keeps this endpoint's - // `places: []` contract that the client expects on every error response. - 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({ error: 'Rate limit exceeded', places: [] })); - return; - } - - const requestUrl = new URL(req.url || '', 'http://localhost'); - const latitude = Number(requestUrl.searchParams.get('lat')); - const longitude = Number(requestUrl.searchParams.get('lon')); - const radiusM = Math.max(25, Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250)); - if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'Valid lat and lon are required', places: [] })); - return; - } - - try { - const response = await fetch('https://places.googleapis.com/v1/places:searchNearby', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': [ - 'places.id', - 'places.displayName', - 'places.formattedAddress', - 'places.shortFormattedAddress', - 'places.location', - 'places.primaryType', - 'places.primaryTypeDisplayName', - 'places.types', - ].join(','), - }, - body: JSON.stringify({ - maxResultCount: 20, - rankPreference: 'DISTANCE', - locationRestriction: { - circle: { - center: { latitude, longitude }, - radius: radiusM, - }, - }, - }), - }); - const data = await response.json().catch(() => ({})); - const seenPlaces = new Set(); - const places = Array.isArray(data.places) ? data.places - .map((place) => { - const placeLatitude = place.location?.latitude ?? null; - const placeLongitude = place.location?.longitude ?? null; - const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; - return { - id: place.id || null, - name: place.displayName?.text || null, - address: place.shortFormattedAddress || place.formattedAddress || null, - latitude: placeLatitude, - longitude: placeLongitude, - distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), - primaryType: place.primaryTypeDisplayName?.text || place.primaryType || null, - types, - contextPriority: placeContextPriority(types), - }; - }) - .filter((place) => { - const key = `${place.name}:${place.address || ''}`.toLowerCase(); - if (!place.name || seenPlaces.has(key)) return false; - seenPlaces.add(key); - return true; - }) - .sort((a, b) => b.contextPriority - a.contextPriority || a.distanceM - b.distanceM) - .map(({ contextPriority, ...place }) => place) - .slice(0, 20) : []; - - 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({ - places, - error: response.ok ? null : data.error?.message || 'Google Places request failed', - })); - } catch (error) { - res.statusCode = 502; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify({ error: error?.message || 'Google Places request failed', places: [] })); - } - }); - - // Text Search: resolve a named landmark/POI to a real coordinate, biased to - // the view. Geocoding scatters obscure monument/POI names across the city; - // a view-biased Text Search lands on the actual feature. Same key, field - // mask, throttle, and `places: []` error contract as nearby-places above. - middlewares.use('/api/google/text-search', async (req, res) => { - if (req.method !== 'GET') { - res.statusCode = 405; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'Method not allowed', places: [] })); - return; - } - - // Keyless place context has no provider cost, so it resolves before the - // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD - // summary route). - const apiKey = googleServerApiKey(); - const keyless = keylessGooglePlacesResponse(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; - } - - // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. - // Inlined (like nearby-places) so the 429 body keeps the `places: []` - // contract the client expects on every error response. - 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({ error: 'Rate limit exceeded', places: [] })); - return; - } - - const requestUrl = new URL(req.url || '', 'http://localhost'); - const textQuery = String(requestUrl.searchParams.get('q') || '').trim(); - const latitude = Number(requestUrl.searchParams.get('lat')); - const longitude = Number(requestUrl.searchParams.get('lon')); - const radiusM = Math.max(50, Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000)); - if (!textQuery || !Number.isFinite(latitude) || !Number.isFinite(longitude)) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'q, lat and lon are required', places: [] })); - return; - } - - try { - const response = await fetch('https://places.googleapis.com/v1/places:searchText', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': [ - 'places.id', - 'places.displayName', - 'places.formattedAddress', - 'places.location', - 'places.viewport', - 'places.primaryType', - 'places.types', - ].join(','), - }, - body: JSON.stringify({ - textQuery, - locationBias: { - circle: { - center: { latitude, longitude }, - radius: radiusM, - }, - }, - maxResultCount: 5, - }), - }); - const data = await response.json().catch(() => ({})); - const places = Array.isArray(data.places) ? data.places - .map((place) => { - const placeLatitude = place.location?.latitude ?? null; - const placeLongitude = place.location?.longitude ?? null; - const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; - // Places returns a lat/lng bounding box (low/high corners) framing the - // place — no polygon, but enough to SIZE a fallback grounds disc to the - // real feature instead of a blind constant. Normalize to plain numbers. - const vp = place.viewport; - const viewport = ( - Number.isFinite(vp?.low?.latitude) && Number.isFinite(vp?.low?.longitude) - && Number.isFinite(vp?.high?.latitude) && Number.isFinite(vp?.high?.longitude) - ) ? { - low: { latitude: vp.low.latitude, longitude: vp.low.longitude }, - high: { latitude: vp.high.latitude, longitude: vp.high.longitude }, - } : null; - return { - id: place.id || null, - name: place.displayName?.text || null, - address: place.formattedAddress || null, - latitude: placeLatitude, - longitude: placeLongitude, - distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), - primaryType: place.primaryType || null, - types, - viewport, - }; - }) - .filter((place) => place.name) : []; - - 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({ - places, - error: response.ok ? null : data.error?.message || 'Google Places request failed', - })); - } catch (error) { - res.statusCode = 502; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify({ error: error?.message || 'Google Places request failed', places: [] })); - } - }); - } - - return { - name: 'google-places-context-proxy', - configureServer(server) { - install(server.middlewares); - }, - configurePreviewServer(server) { - install(server.middlewares); - }, - }; -} - -function placeContextPriority(types) { - const typeSet = new Set(types); - if (typeSet.has('historical_landmark') || typeSet.has('monument')) return 100; - if (typeSet.has('tourist_attraction') || typeSet.has('museum')) return 90; - if (typeSet.has('premise') || typeSet.has('street_address')) return 75; - if (typeSet.has('point_of_interest')) return 60; - if (typeSet.has('public_bathroom')) return 10; - return 40; -} - -function approximateDistanceM(latA, lonA, latB, lonB) { - if (![latA, lonA, latB, lonB].every(Number.isFinite)) return Number.MAX_SAFE_INTEGER; - const latitudeScale = 111320; - const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); - return Math.round(Math.hypot( - (latB - latA) * latitudeScale, - (lonB - lonA) * longitudeScale - )); -} - const GEV_REALTIME_TOOLS = [ { type: 'function', diff --git a/server/providers/places.js b/server/providers/places.js new file mode 100644 index 000000000..2220dfb26 --- /dev/null +++ b/server/providers/places.js @@ -0,0 +1,6 @@ +/** Place-search and route middleware for Node development servers. */ +export { googlePlacesContextProxy } from './places/google.js'; +export { googleServerApiKey, keylessGooglePlacesResponse } from './places/google-key.js'; +export { installRouteMiddleware } from './places/routes.js'; +export { makeRateLimiter, makeOptInRateLimiter, clientKey } from './common/rate-limit.js'; +export { haversineKm } from './common/geo.js'; diff --git a/server/providers/places/google-key.js b/server/providers/places/google-key.js new file mode 100644 index 000000000..353bc9e24 --- /dev/null +++ b/server/providers/places/google-key.js @@ -0,0 +1,26 @@ +import { resolveGoogleServerKey } from '../../../scripts/google-server-key.mjs'; + +/** + * Optional Google place context is an empty capability when no key is present, + * not a server outage. Returning 200 keeps a deliberately keyless session out + * of the browser error console while preserving an explicit configured flag. + */ +export function keylessGooglePlacesResponse(apiKey) { + if (String(apiKey ?? '').trim()) return null; + return { + statusCode: 200, + payload: { configured: false, error: null, places: [] }, + }; +} + +/** + * 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. + */ +export function googleServerApiKey() { + return resolveGoogleServerKey(process.env); +} diff --git a/server/providers/places/google.js b/server/providers/places/google.js new file mode 100644 index 000000000..62ced55e7 --- /dev/null +++ b/server/providers/places/google.js @@ -0,0 +1,210 @@ +import { googleServerApiKey, keylessGooglePlacesResponse } from './google-key.js'; +import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { projectNearbyPlaces, projectTextSearchPlaces } from '../../../src/data/placeProviderPayloads.js'; + +// Construct lazily after the standalone environment has loaded. +// undefined = not built yet; null = unlimited; fn = active limiter +let _googleRateLimiter; + +/** Google cost endpoint (nearby-places). Null = unlimited (default). */ +function googleRateLimiter() { + if (_googleRateLimiter === undefined) _googleRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_GOOGLE_PER_MIN); + return _googleRateLimiter; +} + +/** Nearby place labels and view-biased text search, with request-time key resolution. */ +export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey } = {}) { + function install(middlewares) { + middlewares.use('/api/google/nearby-places', async (req, res) => { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Method not allowed', places: [] })); + return; + } + + // Keyless place context has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD + // summary route). + const apiKey = resolveApiKey(); + const keyless = keylessGooglePlacesResponse(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; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. + // Inlined (not the shared helper) so the 429 body keeps this endpoint's + // `places: []` contract that the client expects on every error response. + 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({ error: 'Rate limit exceeded', places: [] })); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const latitude = Number(requestUrl.searchParams.get('lat')); + const longitude = Number(requestUrl.searchParams.get('lon')); + const radiusM = Math.max(25, Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250)); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Valid lat and lon are required', places: [] })); + return; + } + + try { + const response = await fetch('https://places.googleapis.com/v1/places:searchNearby', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.shortFormattedAddress', + 'places.location', + 'places.primaryType', + 'places.primaryTypeDisplayName', + 'places.types', + ].join(','), + }, + body: JSON.stringify({ + maxResultCount: 20, + rankPreference: 'DISTANCE', + locationRestriction: { + circle: { + center: { latitude, longitude }, + radius: radiusM, + }, + }, + }), + }); + const data = await response.json().catch(() => ({})); + const places = projectNearbyPlaces(data, latitude, longitude); + + 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({ + places, + error: response.ok ? null : data.error?.message || 'Google Places request failed', + })); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ error: error?.message || 'Google Places request failed', places: [] })); + } + }); + + // Text Search: resolve a named landmark/POI to a real coordinate, biased to + // the view. Geocoding scatters obscure monument/POI names across the city; + // a view-biased Text Search lands on the actual feature. Same key, field + // mask, throttle, and `places: []` error contract as nearby-places above. + middlewares.use('/api/google/text-search', async (req, res) => { + if (req.method !== 'GET') { + res.statusCode = 405; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Method not allowed', places: [] })); + return; + } + + // Keyless place context has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the HUD + // summary route). + const apiKey = resolveApiKey(); + const keyless = keylessGooglePlacesResponse(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; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. + // Inlined (like nearby-places) so the 429 body keeps the `places: []` + // contract the client expects on every error response. + 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({ error: 'Rate limit exceeded', places: [] })); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const textQuery = String(requestUrl.searchParams.get('q') || '').trim(); + const latitude = Number(requestUrl.searchParams.get('lat')); + const longitude = Number(requestUrl.searchParams.get('lon')); + const radiusM = Math.max(50, Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000)); + if (!textQuery || !Number.isFinite(latitude) || !Number.isFinite(longitude)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'q, lat and lon are required', places: [] })); + return; + } + + try { + const response = await fetch('https://places.googleapis.com/v1/places:searchText', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.location', + 'places.viewport', + 'places.primaryType', + 'places.types', + ].join(','), + }, + body: JSON.stringify({ + textQuery, + locationBias: { + circle: { + center: { latitude, longitude }, + radius: radiusM, + }, + }, + maxResultCount: 5, + }), + }); + const data = await response.json().catch(() => ({})); + const places = projectTextSearchPlaces(data, latitude, longitude); + + 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({ + places, + error: response.ok ? null : data.error?.message || 'Google Places request failed', + })); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ error: error?.message || 'Google Places request failed', places: [] })); + } + }); + } + + return { + name: 'google-places-context-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js new file mode 100644 index 000000000..953e9c9a0 --- /dev/null +++ b/server/providers/places/routes.js @@ -0,0 +1,104 @@ +import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; +import { haversineKm } from '../common/geo.js'; +import { readResponseTextCapped } from '../common/http.js'; +import { normalizeRouteProfile, projectRouteResult } from '../../../src/data/placeProviderPayloads.js'; + +/** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */ +const ROUTE_CACHE_MS = 600000; + +const _routeCache = new Map(); + +/** Hard cap on the OSRM route response we will buffer. */ +const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB + +/** Reject routes whose straight-line spans are obviously abusive (km). */ +const ROUTE_MAX_LEG_KM = 600; + +const ROUTE_MAX_TOTAL_KM = 2500; + +const _routeRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 200 }); + +export function installRouteMiddleware(middlewares) { + // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike). + // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...] + middlewares.use('/api/route', async (req, res) => { + const fail = (msg) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: msg })); + }; + try { + if (!_routeRateLimiter(clientKey(req))) { + res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' }); + res.end(JSON.stringify({ ok: false, error: 'rate limited' })); + return; + } + const url = new URL(req.url, 'http://localhost'); + const raw = (url.searchParams.get('profile') || 'foot').toLowerCase(); + const profile = normalizeRouteProfile(raw); + if (!profile) return fail('invalid profile'); + const osrmProfile = profile === 'car' ? 'driving' : profile; + const pairs = (url.searchParams.get('coords') || '').split(';').map((s) => s.trim()).filter(Boolean); + if (pairs.length < 2 || pairs.length > 12) return fail('need 2-12 coordinates'); + const clean = []; + const pts = []; + for (const pr of pairs) { + const parts = pr.split(','); + if (parts.length !== 2) return fail('invalid coordinate'); + const lon = Number(parts[0]); + const lat = Number(parts[1]); + if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { + return fail('invalid coordinate'); + } + clean.push(`${lon},${lat}`); + pts.push([lon, lat]); + } + // Reject obviously-abusive spans — a real walking/driving route is local, + // so a cross-continent request is either a bug or an attempt to drive + // heavy upstream OSRM work. + let totalKm = 0; + for (let i = 1; i < pts.length; i += 1) { + // pts are [lon, lat]; existing haversineKm takes (lat1, lon1, lat2, lon2). + const legKm = haversineKm(pts[i - 1][1], pts[i - 1][0], pts[i][1], pts[i][0]); + if (legKm > ROUTE_MAX_LEG_KM) return fail('route leg too long'); + totalKm += legKm; + } + if (totalKm > ROUTE_MAX_TOTAL_KM) return fail('route too long'); + const coords = clean.join(';'); + const cacheKey = `${profile}|${coords}`; + const now = Date.now(); + const cached = _routeCache.get(cacheKey); + if (cached && now - cached.cachedAt <= ROUTE_CACHE_MS) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(cached.payload)); + return; + } + const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + let osrm; + try { + const upstreamRes = await fetch(upstream, { + signal: controller.signal, + headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, + }); + if (!upstreamRes.ok) return fail('no route found'); + const ctype = upstreamRes.headers.get('content-type') || ''; + if (!ctype.includes('json')) return fail('no route found'); + const text = await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES); + osrm = JSON.parse(text); + } finally { + clearTimeout(timer); + } + const route = osrm?.routes?.[0]; + if (osrm?.code !== 'Ok' || !route?.geometry?.coordinates?.length) return fail('no route found'); + const payload = projectRouteResult(route, profile); + _routeCache.set(cacheKey, { payload, cachedAt: now }); + if (_routeCache.size > 200) _routeCache.delete(_routeCache.keys().next().value); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + } catch (e) { + console.error('[Route Proxy]', e?.message || e); + fail('route proxy error'); + } + }); +} diff --git a/src/data/placeProviderPayloads.js b/src/data/placeProviderPayloads.js new file mode 100644 index 000000000..03d9d1923 --- /dev/null +++ b/src/data/placeProviderPayloads.js @@ -0,0 +1,100 @@ +function placeContextPriority(types) { + const typeSet = new Set(types); + if (typeSet.has('historical_landmark') || typeSet.has('monument')) return 100; + if (typeSet.has('tourist_attraction') || typeSet.has('museum')) return 90; + if (typeSet.has('premise') || typeSet.has('street_address')) return 75; + if (typeSet.has('point_of_interest')) return 60; + if (typeSet.has('public_bathroom')) return 10; + return 40; +} + +function approximateDistanceM(latA, lonA, latB, lonB) { + if (![latA, lonA, latB, lonB].every(Number.isFinite)) return Number.MAX_SAFE_INTEGER; + const latitudeScale = 111320; + const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); + return Math.round(Math.hypot( + (latB - latA) * latitudeScale, + (lonB - lonA) * longitudeScale + )); +} + +export function projectNearbyPlaces(data, latitude, longitude) { + const seenPlaces = new Set(); + const places = Array.isArray(data.places) ? data.places + .map((place) => { + const placeLatitude = place.location?.latitude ?? null; + const placeLongitude = place.location?.longitude ?? null; + const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; + return { + id: place.id || null, + name: place.displayName?.text || null, + address: place.shortFormattedAddress || place.formattedAddress || null, + latitude: placeLatitude, + longitude: placeLongitude, + distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), + primaryType: place.primaryTypeDisplayName?.text || place.primaryType || null, + types, + contextPriority: placeContextPriority(types), + }; + }) + .filter((place) => { + const key = `${place.name}:${place.address || ''}`.toLowerCase(); + if (!place.name || seenPlaces.has(key)) return false; + seenPlaces.add(key); + return true; + }) + .sort((a, b) => b.contextPriority - a.contextPriority || a.distanceM - b.distanceM) + .map(({ contextPriority, ...place }) => place) + .slice(0, 20) : []; + return places; +} + +export function projectTextSearchPlaces(data, latitude, longitude) { + const places = Array.isArray(data.places) ? data.places + .map((place) => { + const placeLatitude = place.location?.latitude ?? null; + const placeLongitude = place.location?.longitude ?? null; + const types = Array.isArray(place.types) ? place.types.slice(0, 8) : []; + // Places returns a lat/lng bounding box (low/high corners) framing the + // place — no polygon, but enough to SIZE a fallback grounds disc to the + // real feature instead of a blind constant. Normalize to plain numbers. + const vp = place.viewport; + const viewport = ( + Number.isFinite(vp?.low?.latitude) && Number.isFinite(vp?.low?.longitude) + && Number.isFinite(vp?.high?.latitude) && Number.isFinite(vp?.high?.longitude) + ) ? { + low: { latitude: vp.low.latitude, longitude: vp.low.longitude }, + high: { latitude: vp.high.latitude, longitude: vp.high.longitude }, + } : null; + return { + id: place.id || null, + name: place.displayName?.text || null, + address: place.formattedAddress || null, + latitude: placeLatitude, + longitude: placeLongitude, + distanceM: approximateDistanceM(latitude, longitude, placeLatitude, placeLongitude), + primaryType: place.primaryType || null, + types, + viewport, + }; + }) + .filter((place) => place.name) : []; + return places; +} + +export function normalizeRouteProfile(raw) { + return (raw === 'car' || raw === 'driving') ? 'car' + : (raw === 'bike' || raw === 'cycling' || raw === 'bicycle') ? 'bike' + : (raw === 'foot' || raw === 'walking' || raw === 'walk') ? 'foot' + : null; +} + +export function projectRouteResult(route, profile) { + return { + ok: true, + profile, + distanceM: Math.round(route.distance), + durationS: Math.round(route.duration), + geometry: route.geometry.coordinates, + }; +} diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs new file mode 100644 index 000000000..a0384d2b0 --- /dev/null +++ b/src/tooling/placeProviders.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { googlePlacesContextProxy, installRouteMiddleware } from '../../server/providers/places.js'; +import { projectNearbyPlaces, projectTextSearchPlaces } from '../../src/data/placeProviderPayloads.js'; + +function install(register) { + const routes = new Map(); + register({ use(route, handler) { routes.set(route, handler); } }); + return async (route, url, method = 'GET', peer = 'fixture') => { + const res = { statusCode: 200, headers: {}, setHeader(name, value) { this.headers[name.toLowerCase()] = value; }, writeHead(status, headers) { this.statusCode = status; for (const [key, value] of Object.entries(headers)) this.setHeader(key, value); }, end(body) { this.body = JSON.parse(body); } }; + await routes.get(route)({ url, method, socket: { remoteAddress: peer } }, res); + return res; + }; +} + +test('nearby labels rank landmarks, deduplicate names/addresses and bound the projection', () => { + const place = (name, types, longitude = 0) => ({ displayName: { text: name }, types, location: { latitude: 0, longitude }, formattedAddress: 'Street' }); + const result = projectNearbyPlaces({ places: [place('Bathroom', ['public_bathroom']), place('Monument', ['monument'], 0.1), place('MONUMENT', ['monument']), ...Array.from({ length: 25 }, (_, i) => place(`POI ${i}`, ['point_of_interest'], i / 100)), { id: 'unnamed' }] }, 0, 0); + assert.equal(result.length, 20); + assert.equal(result[0].name, 'Monument'); + assert.equal(result[0].distanceM, 11132); + assert.equal(result.filter(p => p.name.toLowerCase() === 'monument').length, 1); + assert.ok(result.every(p => !('contextPriority' in p))); + assert.deepEqual(projectNearbyPlaces({}, 0, 0), []); +}); + +test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => { + const viewport = { low: { latitude: 1, longitude: 2 }, high: { latitude: 3, longitude: 4 } }; + const result = projectTextSearchPlaces({ places: [{ displayName: { text: 'Museum' }, viewport, types: Array(12).fill('museum') }, { displayName: { text: 'Park' }, viewport: { low: viewport.low } }] }, 0, 0); + assert.deepEqual(result[0].viewport, viewport); + assert.equal(result[0].distanceM, Number.MAX_SAFE_INTEGER); + assert.equal(result[0].latitude, null); + assert.equal(result[0].types.length, 8); + assert.equal(result[1].viewport, null); +}); + +for (const preview of [false, true]) { + test(`Google middleware resolves credentials per request and preserves search responses (${preview ? 'preview' : 'dev'})`, async t => { + let key = ''; + const plugin = googlePlacesContextProxy({ resolveApiKey: () => key }); + const request = install(middlewares => plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares })); + const calls = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + calls.push({ url, options }); + assert.equal(options.headers['X-Goog-Api-Key'], key); + return Response.json({ places: [{ displayName: { text: 'Museum' }, location: { latitude: 30, longitude: -97 } }] }); + }); + const nearby = '/api/google/nearby-places'; + const search = '/api/google/text-search'; + assert.equal((await request(nearby, '?lat=30&lon=-97')).body.configured, false); + assert.equal(calls.length, 0); + key = 'fixture-server-key'; + const result = await request(nearby, '?lat=30&lon=-97&radiusM=9000'); + assert.equal(result.statusCode, 200); + assert.equal(result.body.places[0].name, 'Museum'); + assert.equal(JSON.parse(calls[0].options.body).locationRestriction.circle.radius, 5000); + assert.equal(result.headers['cache-control'], 'private, max-age=300'); + key = 'rotated-fixture-key'; + assert.equal((await request(search, '?q=museum&lat=30&lon=-97&radiusM=1')).statusCode, 200); + assert.equal(JSON.parse(calls[1].options.body).locationBias.circle.radius, 50); + assert.equal((await request(search, '?lat=30&lon=-97')).statusCode, 400); + assert.equal((await request(nearby, '?lat=bad&lon=-97')).statusCode, 400); + assert.equal((await request(nearby, '', 'POST')).statusCode, 405); + t.mock.method(globalThis, 'fetch', async () => Response.json({ error: { message: 'Denied' } }, { status: 403 })); + assert.deepEqual((await request(search, '?q=museum&lat=30&lon=-97')).body, { places: [], error: 'Denied' }); + }); +} + +test('OSRM routing preserves aliases, cache, span guards and upstream failure behavior', async t => { + const request = install(installRouteMiddleware); + let calls = 0; + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + t.mock.method(globalThis, 'fetch', async url => { + calls++; + assert.match(url, /routed-car\/route\/v1\/driving\//); + return Response.json({ code: 'Ok', routes: [{ distance: 123.6, duration: 80.2, geometry: { coordinates: [[-97, 30], [-97.01, 30.01]] } }] }); + }); + const route = '/api/route'; + const query = '?profile=driving&coords=-97,30;-97.01,30.01'; + const result = await request(route, query); + assert.deepEqual(result.body, { ok: true, profile: 'car', distanceM: 124, durationS: 80, geometry: [[-97, 30], [-97.01, 30.01]] }); + assert.deepEqual((await request(route, query.replace('driving', 'car'))).body, result.body); + assert.equal(calls, 1); + for (const [url, error] of [['?profile=plane&coords=0,0;1,1', 'invalid profile'], ['?coords=0,91;1,1', 'invalid coordinate'], ['?coords=0,0;100,0', 'route leg too long'], ['?coords=0,0', 'need 2-12 coordinates']]) assert.equal((await request(route, url)).body.error, error); + assert.equal(calls, 1); + now += 600001; + t.mock.method(globalThis, 'fetch', async () => new Response('offline', { status: 503 })); + assert.deepEqual((await request(route, query)).body, { ok: false, error: 'no route found' }); + t.mock.method(globalThis, 'fetch', async () => new Response('x', { headers: { 'content-type': 'text/html' } })); + assert.equal((await request(route, query)).body.error, 'no route found'); +});