mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(search): geocode through the server, not the browser
- Google's Geocoding web service refuses referrer-restricted keys, so the browser call forced GOOGLE_MAPS_API_KEY to ship unrestricted - add /api/google/geocode and /api/google/reverse-geocode, using the server key like Places and Street View already do - the browser key now needs the Map Tiles API only
This commit is contained in:
1 parent
0dbde1e36c
commit
c5b1d41cba
15 files changed
+436
-47
No files matched your search
@@ -66,6 +66,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
|
||||
export { googlePlacesContextProxy } from './places.js';
|
||||
export { googleServerApiKey } from './places.js';
|
||||
export { keylessGooglePlacesResponse } from './places.js';
|
||||
export { keylessGoogleGeocodeResponse } from './places.js';
|
||||
export { validateGeocodeAddress } from './places.js';
|
||||
export { geocodeBounds } from './places.js';
|
||||
export { adsbLolFallbackAnchor } from './aircraft/opensky.js';
|
||||
export { readResponseTextCapped } from './common/http.js';
|
||||
export { readResponseJsonCapped } from './common/http.js';
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/** Place-search and route middleware for Node development servers. */
|
||||
export { googlePlacesContextProxy } from './places/google.js';
|
||||
export {
|
||||
googlePlacesContextProxy,
|
||||
validateGeocodeAddress,
|
||||
geocodeBounds,
|
||||
} from './places/google.js';
|
||||
export {
|
||||
googleServerApiKey,
|
||||
keylessGooglePlacesResponse,
|
||||
keylessGoogleGeocodeResponse,
|
||||
} from './places/google-key.js';
|
||||
export { installRouteMiddleware } from './places/routes.js';
|
||||
export {
|
||||
|
||||
@@ -14,12 +14,33 @@ export function keylessGooglePlacesResponse(apiKey) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Google API key for the SERVER-SIDE calls (Places nearby/text search, the
|
||||
* CCTV Street View fallback). These never reach the browser, so this key can
|
||||
* be restricted by server IP and scoped to Places API + Street View Static
|
||||
* API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles +
|
||||
* Geocoding for the browser (#33). Splitting them is opt-in: unset, this
|
||||
* falls back to the shared browser key and nothing changes.
|
||||
* Geocoding speaks Google's own response shape rather than the `places: []`
|
||||
* contract its two siblings use, because the browser adapters read `status`
|
||||
* and `results` directly. The keyless capability reads the same way: a status
|
||||
* that is not `OK` and no results, which the adapters already treat as "this
|
||||
* provider did not answer" and pass to the next geocoder in the chain.
|
||||
*/
|
||||
export function keylessGoogleGeocodeResponse(apiKey) {
|
||||
if (String(apiKey ?? '').trim()) return null;
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: {
|
||||
configured: false,
|
||||
status: 'REQUEST_DENIED',
|
||||
results: [],
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Google API key for the SERVER-SIDE calls (Places nearby/text search,
|
||||
* geocoding, the CCTV Street View fallback). These never reach the browser, so
|
||||
* this key can be restricted by server IP and scoped to Places API + Geocoding
|
||||
* API + Street View Static API — while GOOGLE_MAPS_API_KEY stays
|
||||
* referrer-restricted to Map Tiles for the browser (#33, #363). Splitting them
|
||||
* is opt-in: unset, this falls back to the shared browser key, which then has
|
||||
* to carry every API both sides use and cannot take a referrer restriction.
|
||||
*/
|
||||
export function googleServerApiKey() {
|
||||
return resolveGoogleServerKey(process.env);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
googleServerApiKey,
|
||||
keylessGooglePlacesResponse,
|
||||
keylessGoogleGeocodeResponse,
|
||||
} from './google-key.js';
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import {
|
||||
@@ -42,6 +43,37 @@ export function validatePlacesCoordinates(searchParams) {
|
||||
return { ok: true, latitude, longitude };
|
||||
}
|
||||
|
||||
/** Longest accepted geocoder query. Real place names are far shorter. */
|
||||
const GEOCODE_MAX_QUERY = 200;
|
||||
|
||||
/** Validate a free-text geocoder query before consuming request quota. */
|
||||
export function validateGeocodeAddress(searchParams) {
|
||||
const address = String(searchParams.get('address') || '').trim();
|
||||
if (!address) return { ok: false, error: 'address is required' };
|
||||
if (address.length > GEOCODE_MAX_QUERY)
|
||||
return { ok: false, error: 'address is too long' };
|
||||
return { ok: true, address };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the optional viewport bias, which Google reads as
|
||||
* `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than
|
||||
* refused: the bias only ranks results, so a malformed one costs relevance,
|
||||
* not an answer.
|
||||
*/
|
||||
export function geocodeBounds(searchParams) {
|
||||
const raw = String(searchParams.get('bounds') || '').trim();
|
||||
if (!raw) return null;
|
||||
const corners = raw.split('|');
|
||||
if (corners.length !== 2) return null;
|
||||
const numbers = corners.flatMap((corner) => corner.split(',').map(Number));
|
||||
if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null;
|
||||
const [swLat, swLon, neLat, neLon] = numbers;
|
||||
if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null;
|
||||
if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Nearby place labels and view-biased text search, with request-time key resolution. */
|
||||
export function googlePlacesContextProxy({
|
||||
resolveApiKey = googleServerApiKey,
|
||||
@@ -274,6 +306,129 @@ export function googlePlacesContextProxy({
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Geocoding belongs on the server, not in the page. Google's Geocoding web
|
||||
* service refuses referrer-restricted keys, so calling it from the browser
|
||||
* forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be
|
||||
* left unrestricted (#363). Here the request carries the server key, and
|
||||
* the browser key no longer needs the Geocoding API at all.
|
||||
*
|
||||
* Unlike the two Places routes above, these answer in Google's own shape
|
||||
* (`status` + `results`) rather than the `places: []` contract: the browser
|
||||
* normalizers read that shape directly, and keeping it means the proxy adds
|
||||
* a hop without adding a translation nobody asked for.
|
||||
*/
|
||||
function installGeocodeRoute(path, readQuery) {
|
||||
middlewares.use(path, async (req, res) => {
|
||||
const refuse = (statusCode, error) => {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }),
|
||||
);
|
||||
};
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
refuse(405, 'Method not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
// A keyless geocode has no provider cost, so it resolves before the
|
||||
// paid-endpoint limiter can consume or exhaust quota (mirrors the
|
||||
// Places routes above).
|
||||
const apiKey = resolveApiKey();
|
||||
const keyless = keylessGoogleGeocodeResponse(apiKey);
|
||||
if (keyless) {
|
||||
res.statusCode = keyless.statusCode;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(keyless.payload));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url || '', 'http://localhost');
|
||||
const query = readQuery(requestUrl.searchParams);
|
||||
if (!query.ok) {
|
||||
refuse(400, query.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with
|
||||
// the Places routes because both spend the same Google budget.
|
||||
const _grl = googleRateLimiter();
|
||||
if (_grl && !_grl(clientKey(req))) {
|
||||
res.statusCode = 429;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Retry-After', '5');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'OVER_QUERY_LIMIT',
|
||||
results: [],
|
||||
error: 'Rate limit exceeded',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = new URL(
|
||||
endpoints.geocode ||
|
||||
'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
);
|
||||
for (const [name, value] of Object.entries(query.params))
|
||||
upstream.searchParams.set(name, value);
|
||||
upstream.searchParams.set('key', apiKey);
|
||||
const response = await fetchImpl(upstream.toString(), {
|
||||
redirect: 'error',
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
res.statusCode = response.ok ? 200 : response.status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
data ?? {
|
||||
status: 'UNKNOWN_ERROR',
|
||||
results: [],
|
||||
error: 'Google Geocoding request failed',
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'UNKNOWN_ERROR',
|
||||
results: [],
|
||||
error: error?.message || 'Google Geocoding request failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
installGeocodeRoute('/api/google/geocode', (searchParams) => {
|
||||
const address = validateGeocodeAddress(searchParams);
|
||||
if (!address.ok) return address;
|
||||
const bounds = geocodeBounds(searchParams);
|
||||
return {
|
||||
ok: true,
|
||||
params: {
|
||||
address: address.address,
|
||||
...(bounds ? { bounds } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => {
|
||||
const coordinates = validatePlacesCoordinates(searchParams);
|
||||
if (!coordinates.ok) return coordinates;
|
||||
const { latitude, longitude } = coordinates;
|
||||
return { ok: true, params: { latlng: `${latitude},${longitude}` } };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user