mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #323: separate place-search and routing providers
Extract focused Node middleware and portable response projections; preserve standalone behavior.
This commit is contained in:
13 files changed
+940
-492
No files matched your search
+18
-1
@@ -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
|
Keep runtime module discovery out of these exports. When extracting another
|
||||||
component, add its ownership and consumer tests together. Node services must use
|
component, add its ownership and consumer tests together. Node services must use
|
||||||
separate entry points and their own checks when they become reusable; importing
|
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
|
`gods-eye-view/application` owns construction order, startup state, cancellation
|
||||||
and disposal of caller-supplied components. Its only owned module is
|
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
|
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
|
||||||
records without importing Node middleware or a renderer. Browser layer/controller
|
records without importing Node middleware or a renderer. Browser layer/controller
|
||||||
separation is outside this server extraction.
|
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.
|
||||||
+6
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "gods-eye-view",
|
"name": "gods-eye-view",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"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",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bilawal Sidhu",
|
"author": "Bilawal Sidhu",
|
||||||
@@ -73,6 +73,10 @@
|
|||||||
"./server/providers/live": {
|
"./server/providers/live": {
|
||||||
"node": "./server/providers/live.js"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,5 +44,13 @@
|
|||||||
"src/tooling/liveProviders.test.mjs",
|
"src/tooling/liveProviders.test.mjs",
|
||||||
"src/data/adsbLolFallback.js",
|
"src/data/adsbLolFallback.js",
|
||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js"
|
"src/data/aisWatchdog.js",
|
||||||
|
"server/providers/common/geo.js",
|
||||||
|
"server/providers/common/rate-limit.js",
|
||||||
|
"server/providers/places.js",
|
||||||
|
"server/providers/places/google-key.js",
|
||||||
|
"server/providers/places/google.js",
|
||||||
|
"server/providers/places/routes.js",
|
||||||
|
"src/data/placeProviderPayloads.js",
|
||||||
|
"src/tooling/placeProviders.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -51,5 +51,26 @@
|
|||||||
"exports": ["./sources/adsb-lol"],
|
"exports": ["./sources/adsb-lol"],
|
||||||
"modules": ["src/data/adsbLolFallback.js"],
|
"modules": ["src/data/adsbLolFallback.js"],
|
||||||
"external": []
|
"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": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
+4
-488
@@ -24,7 +24,8 @@
|
|||||||
* @module server/providers/local
|
* @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 { openSkyProxy, adsbLolFallbackAnchor } from './aircraft/opensky.js';
|
||||||
import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
import { adsbLolProxy } from './aircraft/adsb-lol.js';
|
||||||
@@ -339,9 +340,6 @@ async function readStaleOverpass(cacheKey) {
|
|||||||
const cached = _overpassCache.get(cacheKey);
|
const cached = _overpassCache.get(cacheKey);
|
||||||
return overpassPayloadIsData(cached) ? cached : readOverpassDisk(cacheKey, Infinity);
|
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 --------------------
|
// --- Abuse guards shared by the Overpass + route proxies --------------------
|
||||||
/** Max accepted POST body for the Overpass proxy (Overpass QL queries are tiny). */
|
/** 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. */
|
/** Max concurrent in-flight upstream Overpass fetches across all distinct queries. */
|
||||||
const OVERPASS_MAX_CONCURRENT = 6;
|
const OVERPASS_MAX_CONCURRENT = 6;
|
||||||
let _overpassConcurrent = 0;
|
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 _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 });
|
||||||
const _militaryInstallationsRateLimiter = 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
|
// 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
|
// (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
|
// 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;
|
// 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).
|
// 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 _openAiRateLimiter;
|
||||||
let _googleRateLimiter;
|
|
||||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
|
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
|
||||||
function openAiRateLimiter() {
|
function openAiRateLimiter() {
|
||||||
if (_openAiRateLimiter === undefined) _openAiRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_OPENAI_PER_MIN);
|
if (_openAiRateLimiter === undefined) _openAiRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_OPENAI_PER_MIN);
|
||||||
return _openAiRateLimiter;
|
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.
|
* 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;
|
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. */
|
/** Server-side timeout ceiling (seconds) we allow inside an Overpass QL query. */
|
||||||
const OVERPASS_MAX_QL_TIMEOUT = 30;
|
const OVERPASS_MAX_QL_TIMEOUT = 30;
|
||||||
/** Max `around:` radius (m) — every app caller uses <= 1800 m. */
|
/** 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).
|
installRouteMiddleware(server.middlewares);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3017,24 +2853,6 @@ function rowArrayToObject(row, columns) {
|
|||||||
return record;
|
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
|
* Distance-prioritizes cameras to a cap: keeps the maxCount cameras closest
|
||||||
* to ANY of the given anchor points (min distance over anchors), tie-broken
|
* 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 = [
|
const GEV_REALTIME_TOOLS = [
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** 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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user