mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
498 lines
18 KiB
JavaScript
498 lines
18 KiB
JavaScript
import {
|
|
googleServerApiKey,
|
|
keylessGooglePlacesResponse,
|
|
keylessGoogleGeocodeResponse,
|
|
} from './google-key.js';
|
|
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
|
|
import { admitSameSite } from '../common/same-site.js';
|
|
import { readResponseJsonCapped } from '../common/http.js';
|
|
import {
|
|
projectNearbyPlaces,
|
|
projectTextSearchPlaces,
|
|
} from '../../../src/data/placeProviderPayloads.js';
|
|
|
|
// Construct lazily after the standalone environment has loaded.
|
|
// undefined = not built yet; null = the explicit 0 opt-out; fn = active limiter
|
|
let _googleRateLimiter;
|
|
|
|
/**
|
|
* Requests/min/IP applied when GEV_RATELIMIT_GOOGLE_PER_MIN is unset. This is
|
|
* the value the Pinokio build already ships (pinokio/_ENVIRONMENT), so the
|
|
* packaged app keeps behaving exactly as it does today and only an
|
|
* unconfigured server changes — from unlimited to what the product already
|
|
* runs with. Places calls are user-driven (a search, a nearby lookup, an
|
|
* installation probe) rather than polled, so this sits far above what the app
|
|
* generates and bites only a caller enumerating the endpoint. `.env.example`
|
|
* suggests a tighter 60 when the host is not localhost; that still applies.
|
|
*/
|
|
export const GOOGLE_DEFAULT_PER_MIN = 120;
|
|
|
|
/** Google cost endpoints (nearby-places, text-search, geocoding). Null only when set to 0. */
|
|
function googleRateLimiter() {
|
|
if (_googleRateLimiter === undefined)
|
|
_googleRateLimiter = makeCostRateLimiter(
|
|
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
|
GOOGLE_DEFAULT_PER_MIN,
|
|
);
|
|
return _googleRateLimiter;
|
|
}
|
|
|
|
/** Validate raw lat/lon presence and WGS84 bounds before consuming request quota. */
|
|
export function validatePlacesCoordinates(searchParams) {
|
|
const rawLat = searchParams.get('lat');
|
|
const rawLon = searchParams.get('lon');
|
|
if (rawLat === null || rawLon === null || !rawLat.trim() || !rawLon.trim()) {
|
|
return { ok: false, error: 'lat and lon are required' };
|
|
}
|
|
const latitude = Number(rawLat);
|
|
const longitude = Number(rawLon);
|
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
return { ok: false, error: 'Valid lat and lon are required' };
|
|
}
|
|
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
|
return {
|
|
ok: false,
|
|
error: 'lat must be within [-90, 90] and lon within [-180, 180]',
|
|
};
|
|
}
|
|
return { ok: true, latitude, longitude };
|
|
}
|
|
|
|
/** Longest accepted geocoder query. Real place names are far shorter. */
|
|
const GEOCODE_MAX_QUERY = 200;
|
|
|
|
/**
|
|
* Upstream deadline and body ceiling for the geocode routes. The deadline stays
|
|
* armed through body consumption, so a stalled upstream cannot hold a handler
|
|
* open, and the cap bounds a chunked or length-omitted reply. A geocode answer
|
|
* is a handful of results; 256 KiB is far above any honest one.
|
|
*/
|
|
const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000;
|
|
|
|
const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024;
|
|
|
|
/** Validate a free-text geocoder query before consuming request quota. */
|
|
export function validateGeocodeAddress(searchParams) {
|
|
const address = String(searchParams.get('address') || '').trim();
|
|
if (!address) return { ok: false, error: 'address is required' };
|
|
if (address.length > GEOCODE_MAX_QUERY)
|
|
return { ok: false, error: 'address is too long' };
|
|
return { ok: true, address };
|
|
}
|
|
|
|
/**
|
|
* Validate the optional viewport bias, which Google reads as
|
|
* `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than
|
|
* refused: the bias only ranks results, so a malformed one costs relevance,
|
|
* not an answer.
|
|
*/
|
|
export function geocodeBounds(searchParams) {
|
|
const raw = String(searchParams.get('bounds') || '').trim();
|
|
if (!raw) return null;
|
|
const corners = raw.split('|');
|
|
if (corners.length !== 2) return null;
|
|
const parts = corners.flatMap((corner) => corner.split(','));
|
|
// A blank component must be refused before Number() turns it into 0 and a
|
|
// half-written corner like `30.4,` reads as the equator.
|
|
if (parts.length !== 4 || parts.some((part) => !part.trim())) return null;
|
|
const numbers = parts.map(Number);
|
|
if (!numbers.every(Number.isFinite)) return null;
|
|
const [swLat, swLon, neLat, neLon] = numbers;
|
|
if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null;
|
|
if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null;
|
|
return raw;
|
|
}
|
|
|
|
/** Nearby place labels and view-biased text search, with request-time key resolution. */
|
|
export function googlePlacesContextProxy({
|
|
resolveApiKey = googleServerApiKey,
|
|
fetchImpl = (...args) => fetch(...args),
|
|
endpoints = {},
|
|
} = {}) {
|
|
function install(middlewares) {
|
|
middlewares.use('/api/google/nearby-places', async (req, res) => {
|
|
// Gate first, like the OpenAI routes: a cross-site caller learns nothing
|
|
// about this endpoint's method surface.
|
|
if (admitSameSite(req, res)) return;
|
|
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;
|
|
}
|
|
|
|
const requestUrl = new URL(req.url || '', 'http://localhost');
|
|
const coordinates = validatePlacesCoordinates(requestUrl.searchParams);
|
|
if (!coordinates.ok) {
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: coordinates.error, places: [] }));
|
|
return;
|
|
}
|
|
const { latitude, longitude } = coordinates;
|
|
|
|
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
|
// 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 radiusM = Math.max(
|
|
25,
|
|
Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250),
|
|
);
|
|
|
|
try {
|
|
const response = await fetchImpl(
|
|
endpoints.nearby ||
|
|
'https://places.googleapis.com/v1/places:searchNearby',
|
|
{
|
|
method: 'POST',
|
|
redirect: 'error',
|
|
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) => {
|
|
// Paid like nearby-places, so gated the same way, first.
|
|
if (admitSameSite(req, res)) return;
|
|
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;
|
|
}
|
|
|
|
const requestUrl = new URL(req.url || '', 'http://localhost');
|
|
const textQuery = String(requestUrl.searchParams.get('q') || '').trim();
|
|
if (!textQuery) {
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(
|
|
JSON.stringify({ error: 'q, lat and lon are required', places: [] }),
|
|
);
|
|
return;
|
|
}
|
|
const coordinates = validatePlacesCoordinates(requestUrl.searchParams);
|
|
if (!coordinates.ok) {
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: coordinates.error, places: [] }));
|
|
return;
|
|
}
|
|
const { latitude, longitude } = coordinates;
|
|
|
|
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
|
// 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 radiusM = Math.max(
|
|
50,
|
|
Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000),
|
|
);
|
|
|
|
try {
|
|
const response = await fetchImpl(
|
|
endpoints.textSearch ||
|
|
'https://places.googleapis.com/v1/places:searchText',
|
|
{
|
|
method: 'POST',
|
|
redirect: 'error',
|
|
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: [],
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Geocoding belongs on the server, not in the page. Google's Geocoding web
|
|
* service refuses referrer-restricted keys, so calling it from the browser
|
|
* forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be
|
|
* left unrestricted (#363). Here the request carries the server key, and
|
|
* the browser key no longer needs the Geocoding API at all.
|
|
*
|
|
* Unlike the two Places routes above, these answer in Google's own shape
|
|
* (`status` + `results`) rather than the `places: []` contract: the browser
|
|
* normalizers read that shape directly, and keeping it means the proxy adds
|
|
* a hop without adding a translation nobody asked for.
|
|
*/
|
|
function installGeocodeRoute(path, readQuery) {
|
|
middlewares.use(path, async (req, res) => {
|
|
const refuse = (statusCode, error) => {
|
|
res.statusCode = statusCode;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(
|
|
JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }),
|
|
);
|
|
};
|
|
|
|
// Gate first, like the Places routes above: a cross-site caller learns
|
|
// nothing about this endpoint's method surface.
|
|
if (admitSameSite(req, res)) return;
|
|
if (req.method !== 'GET') {
|
|
refuse(405, 'Method not allowed');
|
|
return;
|
|
}
|
|
|
|
// A keyless geocode has no provider cost, so it resolves before the
|
|
// paid-endpoint limiter can consume or exhaust quota (mirrors the
|
|
// Places routes above).
|
|
const apiKey = resolveApiKey();
|
|
const keyless = keylessGoogleGeocodeResponse(apiKey);
|
|
if (keyless) {
|
|
res.statusCode = keyless.statusCode;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.end(JSON.stringify(keyless.payload));
|
|
return;
|
|
}
|
|
|
|
const requestUrl = new URL(req.url || '', 'http://localhost');
|
|
const query = readQuery(requestUrl.searchParams);
|
|
if (!query.ok) {
|
|
refuse(400, query.error);
|
|
return;
|
|
}
|
|
|
|
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with the
|
|
// Places routes because both spend the same Google budget.
|
|
const _grl = googleRateLimiter();
|
|
if (_grl && !_grl(clientKey(req))) {
|
|
res.statusCode = 429;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Retry-After', '5');
|
|
res.end(
|
|
JSON.stringify({
|
|
status: 'OVER_QUERY_LIMIT',
|
|
results: [],
|
|
error: 'Rate limit exceeded',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const upstream = new URL(
|
|
endpoints.geocode ||
|
|
'https://maps.googleapis.com/maps/api/geocode/json',
|
|
);
|
|
for (const [name, value] of Object.entries(query.params))
|
|
upstream.searchParams.set(name, value);
|
|
upstream.searchParams.set('key', apiKey);
|
|
const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS);
|
|
const response = await fetchImpl(upstream.toString(), {
|
|
redirect: 'error',
|
|
signal: deadline,
|
|
});
|
|
// An unreadable, oversized or timed-out body is not an answer: it
|
|
// falls through to the same shaped failure as a refusal below.
|
|
const data = await readResponseJsonCapped(
|
|
response,
|
|
GEOCODE_MAX_RESPONSE_BYTES,
|
|
deadline,
|
|
).catch(() => null);
|
|
|
|
// A body we could not read is an upstream failure, not an answer with
|
|
// a status of its own, so it does not borrow the upstream's code.
|
|
if (!data) {
|
|
res.statusCode = 502;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(
|
|
JSON.stringify({
|
|
status: 'UNKNOWN_ERROR',
|
|
results: [],
|
|
error: 'Google Geocoding request failed',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = response.ok ? 200 : response.status;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
|
res.end(JSON.stringify(data));
|
|
} catch {
|
|
// Fixed public text: a transport, URL or runtime message would
|
|
// describe the server to the browser (SECURITY.md, sanitized errors).
|
|
res.statusCode = 502;
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.end(
|
|
JSON.stringify({
|
|
status: 'UNKNOWN_ERROR',
|
|
results: [],
|
|
error: 'Google Geocoding request failed',
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
installGeocodeRoute('/api/google/geocode', (searchParams) => {
|
|
const address = validateGeocodeAddress(searchParams);
|
|
if (!address.ok) return address;
|
|
const bounds = geocodeBounds(searchParams);
|
|
return {
|
|
ok: true,
|
|
params: {
|
|
address: address.address,
|
|
...(bounds ? { bounds } : {}),
|
|
},
|
|
};
|
|
});
|
|
|
|
installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => {
|
|
const coordinates = validatePlacesCoordinates(searchParams);
|
|
if (!coordinates.ok) return coordinates;
|
|
const { latitude, longitude } = coordinates;
|
|
return { ok: true, params: { latlng: `${latitude},${longitude}` } };
|
|
});
|
|
}
|
|
|
|
return {
|
|
name: 'google-places-context-proxy',
|
|
configureServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
configurePreviewServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
};
|
|
}
|