mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
# Conflicts: # README.md # SECURITY.md # scripts/format-scope.json # server/providers/overpass.js
293 lines
11 KiB
JavaScript
293 lines
11 KiB
JavaScript
import {
|
|
makeRateLimiter,
|
|
clientKey,
|
|
clientNetworkKey,
|
|
isLoopbackPeer,
|
|
} from './common/rate-limit.js';
|
|
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
|
import { readRequestBodyCapped } from './common/request.js';
|
|
import { admitSameSiteKeyless } from './common/same-site.js';
|
|
import {
|
|
OVERPASS_MAX_BODY_BYTES,
|
|
OVERPASS_MAX_CONCURRENT,
|
|
resolveOverpassUpstreams,
|
|
} from './overpass/constants.js';
|
|
import { sanitizeOverpassBody } from './overpass/query.js';
|
|
import {
|
|
resolveOverpassPreflight,
|
|
_overpassCache,
|
|
readOverpassDisk,
|
|
overpassDiskTtlMs,
|
|
readStaleOverpass,
|
|
trimOverpassCache,
|
|
writeOverpassDisk,
|
|
} from './overpass/cache.js';
|
|
import {
|
|
overpassPayloadIsData,
|
|
fetchOverpassPayload,
|
|
overpassNotConfigured,
|
|
} from './overpass/transport.js';
|
|
import { installRouteMiddleware } from './places/routes.js';
|
|
|
|
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
|
|
const _overpassInFlight = new Map();
|
|
|
|
let _overpassConcurrent = 0;
|
|
|
|
// Bound body-reading and validation work, including malformed requests and
|
|
// cache hits, before a byte is read. Remote peers share one global window so
|
|
// address rotation cannot mint admission without bound (IPv6 peers are keyed
|
|
// by /64 for the same reason). A direct loopback request — the owner's own
|
|
// browser and the repo QA harnesses, with no forwarding headers — has its own
|
|
// per-peer window outside that global, so busy LAN peers cannot lock the owner
|
|
// out of the map. A request relayed by a reverse proxy on this machine also
|
|
// arrives from loopback, but it is remote traffic: it is admitted from the
|
|
// remote pool under its own socket-keyed bucket, never from the owner's. The
|
|
// forwarded client address is not trusted as identity. Only admission is
|
|
// isolated: the upstream budget below (90/min per key, 300/min globally) is
|
|
// shared, so an owner request that misses cache can still be refused there.
|
|
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
|
|
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
|
|
const _overpassPeerAdmission = makeRateLimiter({
|
|
...OVERPASS_ADMISSION_WINDOW,
|
|
globalMax: 900,
|
|
});
|
|
|
|
const _overpassRateLimiter = makeRateLimiter({
|
|
windowMs: 60_000,
|
|
max: 90,
|
|
globalMax: 300,
|
|
});
|
|
|
|
/**
|
|
* Classify a request for admission. Direct loopback — a loopback socket with
|
|
* no forwarding header — is the owner's own pool; everything else, including a
|
|
* loopback socket that carries forwarding headers (a local reverse proxy), is
|
|
* remote. A proxied request keeps its socket identity under a `proxied:`
|
|
* prefix, so it never shares a bucket with the owner's direct requests.
|
|
* @param {import('http').IncomingMessage} req
|
|
* @returns {{direct: boolean, key: string}}
|
|
*/
|
|
function overpassAdmissionClass(req) {
|
|
const peer = clientNetworkKey(req);
|
|
if (!isLoopbackPeer(clientKey(req))) return { direct: false, key: peer };
|
|
if (hasProxySignals(req.headers || {}))
|
|
return { direct: false, key: `proxied:${peer}` };
|
|
return { direct: true, key: peer };
|
|
}
|
|
|
|
/**
|
|
* Spend one admission slot for this request; false when its bucket is over its
|
|
* window (or, for remote traffic, when the shared global window is full).
|
|
* @param {{direct: boolean, key: string}} admission - `overpassAdmissionClass(req)`.
|
|
* @returns {boolean}
|
|
*/
|
|
function admitOverpassRequest({ direct, key }) {
|
|
return (direct ? _overpassLoopbackAdmission : _overpassPeerAdmission)(key);
|
|
}
|
|
|
|
/**
|
|
* Write a completed Overpass payload to the HTTP response.
|
|
*
|
|
* @param {import('http').ServerResponse} res - Node HTTP response.
|
|
* @param {{status:number,body:string,contentType:string,endpoint:string}} payload
|
|
* @param {string} [cacheStatus='MISS'] - 'HIT', 'MISS', or 'INFLIGHT'.
|
|
*/
|
|
function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
|
|
res.writeHead(payload.status, {
|
|
'Content-Type': payload.contentType || 'application/json',
|
|
'Cache-Control': overpassPayloadIsData(payload)
|
|
? 'public, max-age=15'
|
|
: 'no-store',
|
|
'X-Overpass-Cache': cacheStatus,
|
|
...(Number.isFinite(payload.cachedAt)
|
|
? { 'X-Overpass-Cached-At': new Date(payload.cachedAt).toISOString() }
|
|
: {}),
|
|
...(payload.retryAfterMs
|
|
? { 'Retry-After': String(Math.ceil(payload.retryAfterMs / 1000)) }
|
|
: {}),
|
|
});
|
|
res.end(payload.body || '');
|
|
}
|
|
|
|
/**
|
|
* Vite plugin: Overpass API proxy with response caching and request coalescing.
|
|
*
|
|
* Accepts POST requests at /api/overpass, normalizes the query body for
|
|
* cache keying, and uses only operator-configured endpoints with per-upstream
|
|
* timeout and rate-limit detection. Successful responses are cached for
|
|
* OVERPASS_CACHE_MS. Concurrent identical queries share a single upstream
|
|
* request via the in-flight map.
|
|
*
|
|
* @returns {import('vite').Plugin}
|
|
*/
|
|
function overpassProxy({ routing = {} } = {}) {
|
|
const installMiddleware = (server) => {
|
|
server.middlewares.use('/api/overpass', async (req, res) => {
|
|
// Hoisted out of the try so the catch's serve-stale lookup can see it
|
|
// (a body-read failure would otherwise hit an out-of-scope reference).
|
|
let cacheKey = null;
|
|
try {
|
|
// Capability probe: lets clients skip queries when nothing is configured.
|
|
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(
|
|
JSON.stringify({
|
|
configured: resolveOverpassUpstreams().length > 0,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (req.method !== 'POST') {
|
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
return;
|
|
}
|
|
|
|
// Keyless gate: refuses cross-site browser requests but, unlike the
|
|
// cost-bearing routes, admits the forwarding headers a reverse proxy
|
|
// adds — the layers that depend on Overpass must keep working for an
|
|
// instance deployed the way the README documents.
|
|
if (admitSameSiteKeyless(req, res)) return;
|
|
const admission = overpassAdmissionClass(req);
|
|
if (!admitOverpassRequest(admission)) {
|
|
res.writeHead(429, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
'Retry-After': '60',
|
|
});
|
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
return;
|
|
}
|
|
|
|
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
|
let body;
|
|
try {
|
|
body = (
|
|
await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES)
|
|
).toString();
|
|
} catch (err) {
|
|
if (err?.code === 'BODY_TOO_LARGE') {
|
|
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Overpass query too large' }));
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
if (!body) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Missing Overpass query body' }));
|
|
return;
|
|
}
|
|
|
|
// Validate + clamp the QL: reject unbounded/global queries and cap the
|
|
// server-side timeout so a tiny body can't request planet-scale work.
|
|
const sanitized = sanitizeOverpassBody(body);
|
|
if (!sanitized.ok) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: sanitized.error }));
|
|
return;
|
|
}
|
|
const safeBody = sanitized.body;
|
|
|
|
// Normalize whitespace so semantically identical Overpass QL queries share cache entries
|
|
cacheKey = safeBody.replace(/\s+/g, ' ').trim();
|
|
if (!resolveOverpassUpstreams().length) {
|
|
const cached = await readStaleOverpass(cacheKey);
|
|
sendOverpassResponse(
|
|
res,
|
|
cached || overpassNotConfigured(),
|
|
cached ? 'STALE' : 'DISABLED',
|
|
);
|
|
return;
|
|
}
|
|
const preflight = await resolveOverpassPreflight({
|
|
cacheKey,
|
|
memoryCache: _overpassCache,
|
|
inFlight: _overpassInFlight,
|
|
// Fresh-enough disk entries survive restarts and skip upstream
|
|
// requests; boundary-class queries keep their month-long TTL.
|
|
readDisk: () =>
|
|
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
|
allowUpstream: () => _overpassRateLimiter(admission.key),
|
|
loadUpstream: async () => {
|
|
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
|
return {
|
|
status: 503,
|
|
contentType: 'application/json',
|
|
retryAfterMs: 2000,
|
|
body: JSON.stringify({
|
|
error: 'Overpass proxy busy — try again shortly',
|
|
}),
|
|
};
|
|
_overpassConcurrent++;
|
|
try {
|
|
const payload = await fetchOverpassPayload(safeBody);
|
|
if (overpassPayloadIsData(payload)) {
|
|
const entry = { ...payload, cachedAt: Date.now() };
|
|
_overpassCache.set(cacheKey, entry);
|
|
trimOverpassCache();
|
|
writeOverpassDisk(cacheKey, entry);
|
|
}
|
|
return payload;
|
|
} finally {
|
|
_overpassConcurrent--;
|
|
}
|
|
},
|
|
});
|
|
if (preflight.source === 'RATE_LIMITED') {
|
|
res.writeHead(429, {
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': '5',
|
|
});
|
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
|
return;
|
|
}
|
|
if (!overpassPayloadIsData(preflight.payload)) {
|
|
const stale = await readStaleOverpass(cacheKey);
|
|
if (stale) {
|
|
sendOverpassResponse(res, stale, 'STALE');
|
|
return;
|
|
}
|
|
}
|
|
// Preflight already published the disk hit to memory; only trim.
|
|
if (preflight.source === 'DISK') trimOverpassCache();
|
|
sendOverpassResponse(
|
|
res,
|
|
preflight.payload,
|
|
preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source,
|
|
);
|
|
} catch (e) {
|
|
// Every mirror threw (network-level). Same serve-stale rule.
|
|
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
|
|
if (stale) {
|
|
sendOverpassResponse(res, stale, 'STALE');
|
|
return;
|
|
}
|
|
console.error('[Overpass Proxy] request failed');
|
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Overpass proxy error' }));
|
|
}
|
|
});
|
|
|
|
installRouteMiddleware(server.middlewares, routing);
|
|
};
|
|
return {
|
|
name: 'overpass-proxy',
|
|
configureServer: installMiddleware,
|
|
configurePreviewServer: installMiddleware,
|
|
};
|
|
}
|
|
|
|
export { overpassProxy };
|
|
|
|
export { isOverpassBoundaryQuery } from './overpass/query.js';
|
|
export { simplifyOverpassPayloadBody } from './overpass/geometry.js';
|
|
export { readOverpassDisk } from './overpass/cache.js';
|
|
export { resolveOverpassPreflight } from './overpass/cache.js';
|
|
export { overpassPayloadIsData } from './overpass/transport.js';
|
|
export { fetchOverpassPayload } from './overpass/transport.js';
|