diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 418d9da0c..7a34588da 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -13,49 +13,51 @@ export function adsbLolProxy() { let _cacheAt = 0; /** Response cache TTL (ms). */ const CACHE_MS = 12000; - return { - name: 'adsblol-proxy', - configureServer(server) { - server.middlewares.use('/api/adsblol/mil', async (req, res) => { - try { - const now = Date.now(); - if (_cache && now - _cacheAt < CACHE_MS) { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'X-ADS-B-Cache': 'HIT', - }); - res.end(_cache); - return; - } - const upstream = await fetch('https://api.adsb.lol/v2/mil', { - headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, - }); - const body = await upstream.text(); - if (upstream.ok) { - _cache = body; - _cacheAt = now; - } - res.writeHead(upstream.status, { + const installMiddleware = (server) => { + server.middlewares.use('/api/adsblol/mil', async (req, res) => { + try { + const now = Date.now(); + if (_cache && now - _cacheAt < CACHE_MS) { + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', - 'X-ADS-B-Cache': 'MISS', + 'X-ADS-B-Cache': 'HIT', }); - res.end(body); - } catch (e) { - console.error('[adsb.lol Proxy]', e.message); - if (_cache) { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'X-ADS-B-Cache': 'STALE', - }); - res.end(_cache); - return; - } - res.writeHead(502, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'ADS-B proxy error' })); + res.end(_cache); + return; } - }); - }, + const upstream = await fetch('https://api.adsb.lol/v2/mil', { + headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, + }); + const body = await upstream.text(); + if (upstream.ok) { + _cache = body; + _cacheAt = now; + } + res.writeHead(upstream.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-ADS-B-Cache': 'MISS', + }); + res.end(body); + } catch (e) { + console.error('[adsb.lol Proxy]', e.message); + if (_cache) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'X-ADS-B-Cache': 'STALE', + }); + res.end(_cache); + return; + } + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'ADS-B proxy error' })); + } + }); + }; + return { + name: 'adsblol-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js index 67e61e382..400050151 100644 --- a/server/providers/aircraft/enrichment.js +++ b/server/providers/aircraft/enrichment.js @@ -107,45 +107,41 @@ export function adsbdbProxy() { return inflight.get(ik); } + const installMiddleware = (server) => { + server.middlewares.use('/api/adsbdb', async (req, res) => { + await loadOnce(); + const send = (status, obj) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + try { + const [, kind, rawKey] = String(req.url || '') + .split('?')[0] + .split('/'); + if (kind === 'route') { + const cs = String(rawKey || '').toUpperCase(); + if (!/^[A-Z0-9]{2,8}$/.test(cs)) + return send(400, { error: 'invalid callsign' }); + const data = await lookup('route', cs); + return send(200, data ? { found: true, ...data } : { found: false }); + } + if (kind === 'type') { + const hex = String(rawKey || '').toLowerCase(); + if (!/^[0-9a-f]{6}$/.test(hex)) + return send(400, { error: 'invalid hex' }); + const data = await lookup('aircraft', hex); + return send(200, data ? { found: true, ...data } : { found: false }); + } + return send(404, { error: 'unknown endpoint' }); + } catch (err) { + console.error('[adsbdb-proxy] request failed'); + return send(500, { error: 'adsbdb proxy error' }); + } + }); + }; return { name: 'adsbdb-proxy', - configureServer(server) { - server.middlewares.use('/api/adsbdb', async (req, res) => { - await loadOnce(); - const send = (status, obj) => { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(obj)); - }; - try { - const [, kind, rawKey] = String(req.url || '') - .split('?')[0] - .split('/'); - if (kind === 'route') { - const cs = String(rawKey || '').toUpperCase(); - if (!/^[A-Z0-9]{2,8}$/.test(cs)) - return send(400, { error: 'invalid callsign' }); - const data = await lookup('route', cs); - return send( - 200, - data ? { found: true, ...data } : { found: false }, - ); - } - if (kind === 'type') { - const hex = String(rawKey || '').toLowerCase(); - if (!/^[0-9a-f]{6}$/.test(hex)) - return send(400, { error: 'invalid hex' }); - const data = await lookup('aircraft', hex); - return send( - 200, - data ? { found: true, ...data } : { found: false }, - ); - } - return send(404, { error: 'unknown endpoint' }); - } catch (err) { - console.error('[adsbdb-proxy] request failed'); - return send(500, { error: 'adsbdb proxy error' }); - } - }); - }, + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 4a6715faf..612d01e11 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -348,158 +348,23 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { * @returns {import('vite').Plugin} */ export function openSkyProxy() { - return { - name: 'opensky-proxy', - configureServer(server) { - server.middlewares.use('/api/opensky', async (req, res) => { - try { - const requestedMode = normalizeOpenSkyAuthMode( - process.env.OPENSKY_AUTH_MODE, - ); - const now = Date.now(); - const inCooldown = now < _openskyCooldownUntil; - // Fresh-enough cache (adaptive TTL) OR any cache during a 429 - // cooldown: serve it without touching upstream. Stale-during-cooldown - // is deliberate (credit governor): last-good planes beat a dead layer. + const installMiddleware = (server) => { + server.middlewares.use('/api/opensky', async (req, res) => { + try { + const requestedMode = normalizeOpenSkyAuthMode( + process.env.OPENSKY_AUTH_MODE, + ); + const now = Date.now(); + const inCooldown = now < _openskyCooldownUntil; + // Fresh-enough cache (adaptive TTL) OR any cache during a 429 + // cooldown: serve it without touching upstream. Stale-during-cooldown + // is deliberate (credit governor): last-good planes beat a dead layer. + if ( + _openskyCacheBody && + (now - _openskyCacheTime < _openskyTtlMs || inCooldown) + ) { if ( - _openskyCacheBody && - (now - _openskyCacheTime < _openskyTtlMs || inCooldown) - ) { - if ( - openSkySourceIsStale(_openskyCacheSourceEpochMs, now) && - (await serveAdsbLolPointFallback( - req, - res, - requestedMode, - 'opensky_snapshot_stale_regional_fallback', - )) - ) { - return; - } - const cachedMeta = _openskyCacheMeta || { - requestedMode, - usedMode: 'unknown', - reason: 'cached', - }; - const isStale = now - _openskyCacheTime >= _openskyTtlMs; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ - cacheStatus: isStale ? 'STALE' : 'HIT', - requestedMode: cachedMeta.requestedMode || requestedMode, - usedMode: cachedMeta.usedMode || 'unknown', - reason: isStale - ? 'rate_limited_serving_stale' - : cachedMeta.reason || 'cached', - staleSeconds: isStale - ? (now - _openskyCacheTime) / 1000 - : undefined, - retryAfterSeconds: inCooldown - ? (_openskyCooldownUntil - now) / 1000 - : undefined, - }), - ); - res.end(_openskyCacheBody); - return; - } - // Cooling down with nothing cached (cold start into a rate limit): - // synthesize the 429 locally — hammering upstream mid-cooldown can't - // succeed and just burns goodwill. - if (inCooldown) { - if ( - await serveAdsbLolPointFallback( - req, - res, - requestedMode, - 'opensky_cooldown_regional_fallback', - ) - ) - return; - res.writeHead( - 429, - buildOpenSkyHeaders({ - cacheStatus: 'COOLDOWN', - requestedMode, - usedMode: 'none', - reason: 'rate_limited', - retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, - }), - ); - res.end( - JSON.stringify({ - error: 'OpenSky rate limited; proxy cooling down.', - }), - ); - return; - } - - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); - const headers = { Accept: 'application/json' }; - let usedMode = 'anon'; - let reason = 'forced_anonymous'; - - if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; - } - } else if (requestedMode === 'oauth') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else { - reason = 'oauth_invalid_or_missing'; - } - } else if (requestedMode === 'auto') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; - } else { - reason = 'missing_oauth_and_basic_creds'; - } - } - - let upstream = await fetch( - 'https://opensky-network.org/api/states/all?extended=1', - { headers }, - ); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - upstream = await fetch( - 'https://opensky-network.org/api/states/all?extended=1', - { headers: retryHeaders }, - ); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } - - let body = await upstream.text(); - const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; - if ( - upstream.ok && - openSkySourceIsStale(sourceEpochMs, now) && + openSkySourceIsStale(_openskyCacheSourceEpochMs, now) && (await serveAdsbLolPointFallback( req, res, @@ -507,193 +372,330 @@ export function openSkyProxy() { 'opensky_snapshot_stale_regional_fallback', )) ) { - // Keep the last global snapshot available as a fail-soft cache, - // but do not label or render it as a fresh live result. - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { requestedMode, usedMode, reason }; return; } - if (upstream.status === 429) { - reason = 'rate_limited'; - // Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min; - // 2 min when the header is absent) — no upstream attempts until then. - const retryAfterSec = Number( - upstream.headers.get('x-rate-limit-retry-after-seconds'), - ); - const cooldownMs = Math.min( - Math.max( - Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, - 30_000, - ), - 30 * 60_000, - ); - _openskyCooldownUntil = now + cooldownMs; - // Serve the last-good body instead of the 429 when we have one — - // the layer keeps rendering (STALE-cued) instead of dying. - if (_openskyCacheBody && _openskyCacheStatus === 200) { - res.writeHead( - 200, - buildOpenSkyHeaders({ - cacheStatus: 'STALE', - requestedMode, - usedMode, - reason: 'rate_limited_serving_stale', - staleSeconds: (now - _openskyCacheTime) / 1000, - retryAfterSeconds: cooldownMs / 1000, - }), - ); - res.end(_openskyCacheBody); - return; - } - } - - if (!upstream.ok && !_openskyCacheBody) { - const servedFallback = await serveAdsbLolPointFallback( - req, - res, - requestedMode, - `opensky_http_${upstream.status}_regional_fallback`, - ); - if (servedFallback) return; - } - - if (upstream.status === 401 || upstream.status === 403) { - if (requestedMode === 'basic' && !hasBasicCreds) { - body = JSON.stringify({ - error: - 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', - }); - reason = 'missing_basic_creds'; - } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', - }); - reason = 'oauth_invalid_or_missing'; - } else if (usedMode === 'basic') { - body = JSON.stringify({ - error: 'OpenSky auth invalid. Username/password were rejected.', - }); - reason = 'basic_invalid_credentials'; - } else if (usedMode === 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth client credentials were rejected.', - }); - reason = 'oauth_invalid_credentials'; - } else if (requestedMode === 'auto' && !hasBasicCreds) { - body = JSON.stringify({ - error: - 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', - }); - reason = 'missing_oauth_and_basic_creds'; - } else { - body = JSON.stringify({ - error: 'OpenSky auth required.', - }); - reason = 'auth_required'; - } - } - - // Refine the reason string to reflect the actual outcome - if (upstream.ok && reason === 'forced_anonymous') { - reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; - } else if ( - upstream.ok && - usedMode === 'oauth' && - reason === 'oauth_token' - ) { - reason = 'oauth_ok'; - } - - // Only cache successful responses — error responses (401/403/429/5xx) - // should not be served from cache on subsequent requests - if (upstream.ok) { - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { - requestedMode, - usedMode, - reason, - }; - // Credit governor: adapt the cache TTL to the remaining daily - // budget so a continuously-open app stretches its polls instead of - // exhausting the quota mid-day. Success also clears any cooldown. - const remaining = Number( - upstream.headers.get('x-rate-limit-remaining'), - ); - _openskyTtlMs = openskyAdaptiveTtlMs(remaining); - _openskyCooldownUntil = 0; - } - + const cachedMeta = _openskyCacheMeta || { + requestedMode, + usedMode: 'unknown', + reason: 'cached', + }; + const isStale = now - _openskyCacheTime >= _openskyTtlMs; res.writeHead( - upstream.status, + _openskyCacheStatus || 200, buildOpenSkyHeaders({ - cacheStatus: 'MISS', - requestedMode, - usedMode, - reason, + cacheStatus: isStale ? 'STALE' : 'HIT', + requestedMode: cachedMeta.requestedMode || requestedMode, + usedMode: cachedMeta.usedMode || 'unknown', + reason: isStale + ? 'rate_limited_serving_stale' + : cachedMeta.reason || 'cached', + staleSeconds: isStale + ? (now - _openskyCacheTime) / 1000 + : undefined, + retryAfterSeconds: inCooldown + ? (_openskyCooldownUntil - now) / 1000 + : undefined, }), ); - res.end(body); - } catch (e) { - console.error('[OpenSky Proxy]', e.message); - if (_openskyCacheBody) { - const cachedMeta = _openskyCacheMeta || { - requestedMode: normalizeOpenSkyAuthMode( - process.env.OPENSKY_AUTH_MODE, - ), - usedMode: 'unknown', - reason: 'cached_stale', - }; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ - cacheStatus: 'STALE', - requestedMode: - cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT, - usedMode: cachedMeta.usedMode || 'unknown', - reason: cachedMeta.reason || 'cached_stale', - }), - ); - res.end(_openskyCacheBody); - return; - } - const requestedMode = normalizeOpenSkyAuthMode( - process.env.OPENSKY_AUTH_MODE, - ); + res.end(_openskyCacheBody); + return; + } + // Cooling down with nothing cached (cold start into a rate limit): + // synthesize the 429 locally — hammering upstream mid-cooldown can't + // succeed and just burns goodwill. + if (inCooldown) { if ( await serveAdsbLolPointFallback( req, res, requestedMode, - 'opensky_proxy_error_regional_fallback', + 'opensky_cooldown_regional_fallback', ) ) return; res.writeHead( - 502, + 429, buildOpenSkyHeaders({ - cacheStatus: 'MISS', + cacheStatus: 'COOLDOWN', requestedMode, - usedMode: 'error', - reason: 'proxy_error', + usedMode: 'none', + reason: 'rate_limited', + retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, }), ); - res.end(JSON.stringify({ error: 'OpenSky proxy error' })); + res.end( + JSON.stringify({ + error: 'OpenSky rate limited; proxy cooling down.', + }), + ); + return; } - }); - }, + + const basicUser = process.env.OPENSKY_USERNAME || ''; + const basicPass = process.env.OPENSKY_PASSWORD || ''; + const hasBasicCreds = Boolean(basicUser && basicPass); + const headers = { Accept: 'application/json' }; + let usedMode = 'anon'; + let reason = 'forced_anonymous'; + + if (requestedMode === 'basic') { + if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'basic_credentials'; + } else { + reason = 'missing_basic_creds'; + } + } else if (requestedMode === 'oauth') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else { + reason = 'oauth_invalid_or_missing'; + } + } else if (requestedMode === 'auto') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'oauth_unavailable_fallback_basic'; + } else { + reason = 'missing_oauth_and_basic_creds'; + } + } + + let upstream = await fetch( + 'https://opensky-network.org/api/states/all?extended=1', + { headers }, + ); + // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials + if ( + (upstream.status === 401 || upstream.status === 403) && + requestedMode === 'auto' && + usedMode === 'oauth' && + hasBasicCreds + ) { + const retryHeaders = { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, + }; + upstream = await fetch( + 'https://opensky-network.org/api/states/all?extended=1', + { headers: retryHeaders }, + ); + usedMode = 'basic'; + reason = 'oauth_rejected_fallback_basic'; + } + + let body = await upstream.text(); + const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; + if ( + upstream.ok && + openSkySourceIsStale(sourceEpochMs, now) && + (await serveAdsbLolPointFallback( + req, + res, + requestedMode, + 'opensky_snapshot_stale_regional_fallback', + )) + ) { + // Keep the last global snapshot available as a fail-soft cache, + // but do not label or render it as a fresh live result. + _openskyCacheBody = body; + _openskyCacheStatus = upstream.status; + _openskyCacheTime = now; + _openskyCacheSourceEpochMs = sourceEpochMs; + _openskyCacheMeta = { requestedMode, usedMode, reason }; + return; + } + if (upstream.status === 429) { + reason = 'rate_limited'; + // Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min; + // 2 min when the header is absent) — no upstream attempts until then. + const retryAfterSec = Number( + upstream.headers.get('x-rate-limit-retry-after-seconds'), + ); + const cooldownMs = Math.min( + Math.max( + Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, + 30_000, + ), + 30 * 60_000, + ); + _openskyCooldownUntil = now + cooldownMs; + // Serve the last-good body instead of the 429 when we have one — + // the layer keeps rendering (STALE-cued) instead of dying. + if (_openskyCacheBody && _openskyCacheStatus === 200) { + res.writeHead( + 200, + buildOpenSkyHeaders({ + cacheStatus: 'STALE', + requestedMode, + usedMode, + reason: 'rate_limited_serving_stale', + staleSeconds: (now - _openskyCacheTime) / 1000, + retryAfterSeconds: cooldownMs / 1000, + }), + ); + res.end(_openskyCacheBody); + return; + } + } + + if (!upstream.ok && !_openskyCacheBody) { + const servedFallback = await serveAdsbLolPointFallback( + req, + res, + requestedMode, + `opensky_http_${upstream.status}_regional_fallback`, + ); + if (servedFallback) return; + } + + if (upstream.status === 401 || upstream.status === 403) { + if (requestedMode === 'basic' && !hasBasicCreds) { + body = JSON.stringify({ + error: + 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', + }); + reason = 'missing_basic_creds'; + } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { + body = JSON.stringify({ + error: + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + }); + reason = 'oauth_invalid_or_missing'; + } else if (usedMode === 'basic') { + body = JSON.stringify({ + error: 'OpenSky auth invalid. Username/password were rejected.', + }); + reason = 'basic_invalid_credentials'; + } else if (usedMode === 'oauth') { + body = JSON.stringify({ + error: + 'OpenSky auth invalid. OAuth client credentials were rejected.', + }); + reason = 'oauth_invalid_credentials'; + } else if (requestedMode === 'auto' && !hasBasicCreds) { + body = JSON.stringify({ + error: + 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + }); + reason = 'missing_oauth_and_basic_creds'; + } else { + body = JSON.stringify({ + error: 'OpenSky auth required.', + }); + reason = 'auth_required'; + } + } + + // Refine the reason string to reflect the actual outcome + if (upstream.ok && reason === 'forced_anonymous') { + reason = 'anonymous_ok'; + } else if ( + upstream.ok && + usedMode === 'basic' && + reason === 'basic_credentials' + ) { + reason = 'basic_ok'; + } else if ( + upstream.ok && + usedMode === 'oauth' && + reason === 'oauth_token' + ) { + reason = 'oauth_ok'; + } + + // Only cache successful responses — error responses (401/403/429/5xx) + // should not be served from cache on subsequent requests + if (upstream.ok) { + _openskyCacheBody = body; + _openskyCacheStatus = upstream.status; + _openskyCacheTime = now; + _openskyCacheSourceEpochMs = sourceEpochMs; + _openskyCacheMeta = { + requestedMode, + usedMode, + reason, + }; + // Credit governor: adapt the cache TTL to the remaining daily + // budget so a continuously-open app stretches its polls instead of + // exhausting the quota mid-day. Success also clears any cooldown. + const remaining = Number( + upstream.headers.get('x-rate-limit-remaining'), + ); + _openskyTtlMs = openskyAdaptiveTtlMs(remaining); + _openskyCooldownUntil = 0; + } + + res.writeHead( + upstream.status, + buildOpenSkyHeaders({ + cacheStatus: 'MISS', + requestedMode, + usedMode, + reason, + }), + ); + res.end(body); + } catch (e) { + console.error('[OpenSky Proxy]', e.message); + if (_openskyCacheBody) { + const cachedMeta = _openskyCacheMeta || { + requestedMode: normalizeOpenSkyAuthMode( + process.env.OPENSKY_AUTH_MODE, + ), + usedMode: 'unknown', + reason: 'cached_stale', + }; + res.writeHead( + _openskyCacheStatus || 200, + buildOpenSkyHeaders({ + cacheStatus: 'STALE', + requestedMode: + cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT, + usedMode: cachedMeta.usedMode || 'unknown', + reason: cachedMeta.reason || 'cached_stale', + }), + ); + res.end(_openskyCacheBody); + return; + } + const requestedMode = normalizeOpenSkyAuthMode( + process.env.OPENSKY_AUTH_MODE, + ); + if ( + await serveAdsbLolPointFallback( + req, + res, + requestedMode, + 'opensky_proxy_error_regional_fallback', + ) + ) + return; + res.writeHead( + 502, + buildOpenSkyHeaders({ + cacheStatus: 'MISS', + requestedMode, + usedMode: 'error', + reason: 'proxy_error', + }), + ); + res.end(JSON.stringify({ error: 'OpenSky proxy error' })); + } + }); + }; + return { + name: 'opensky-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 17235e97c..668355612 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -119,276 +119,275 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } }; - return { - name: 'cctv-proxy', - configureServer(server) { - server.middlewares.use('/api/cctv', async (req, res) => { - try { - const sources = await getCctvSources(); - const sourceById = new Map( - sources.map((source) => [source.id, source]), - ); - const url = new URL(req.url || '/', 'http://localhost'); + const installMiddleware = (server) => { + server.middlewares.use('/api/cctv', async (req, res) => { + try { + const sources = await getCctvSources(); + const sourceById = new Map( + sources.map((source) => [source.id, source]), + ); + const url = new URL(req.url || '/', 'http://localhost'); - if (url.pathname === '/sources') { - const body = { - sources: sources.map((source) => ({ - id: source.id, - name: source.name, - city: source.city, - cityId: source.cityId, - provider: source.provider, - lat: source.lat, - lon: source.lon, - headingDeg: source.headingDeg, - headingConfidence: source.headingConfidence || '', - pitchDeg: source.pitchDeg, - fovDeg: source.fovDeg, - rangeM: source.rangeM, - mountHeightM: source.mountHeightM, - groundElevationM: source.groundElevationM, - feedType: normalizeFeedType(source.feedType), - sourceKind: - source.sourceKind || (source.url ? 'configured' : 'fallback'), - poseSource: source.poseSource, - license: source.license, - })), + if (url.pathname === '/sources') { + const body = { + sources: sources.map((source) => ({ + id: source.id, + name: source.name, + city: source.city, + cityId: source.cityId, + provider: source.provider, + lat: source.lat, + lon: source.lon, + headingDeg: source.headingDeg, + headingConfidence: source.headingConfidence || '', + pitchDeg: source.pitchDeg, + fovDeg: source.fovDeg, + rangeM: source.rangeM, + mountHeightM: source.mountHeightM, + groundElevationM: source.groundElevationM, + feedType: normalizeFeedType(source.feedType), + sourceKind: + source.sourceKind || (source.url ? 'configured' : 'fallback'), + poseSource: source.poseSource, + license: source.license, + })), + }; + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); + return; + } + + if (url.pathname === '/health') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ cameras: listHealth() })); + return; + } + + if (url.pathname.startsWith('/stream/')) { + const cameraId = + decodeURIComponent(url.pathname.replace('/stream/', '').trim()) || + 'camera'; + const source = sourceById.get(cameraId); + const payload = buildStreamPayload(source, cameraId); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(payload)); + return; + } + + if (url.pathname.startsWith('/media/')) { + const cameraId = + decodeURIComponent(url.pathname.replace('/media/', '').trim()) || + 'camera'; + const source = sourceById.get(cameraId); + const mediaUrl = source?.url || ''; + const feedType = normalizeFeedType(source?.feedType || 'image'); + + if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) { + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'fallback', + label: source?.provider || 'No upstream URL', + message: 'No stream URL configured', + }); + res.writeHead(404, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: 'No media URL configured for this camera', + }), + ); + return; + } + + try { + const upstreamHeaders = { + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', }; - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', + const requestRange = req.headers?.range; + if (requestRange) upstreamHeaders.Range = requestRange; + const upstream = await fetch(mediaUrl, { + headers: upstreamHeaders, }); - res.end(JSON.stringify(body)); - return; - } - - if (url.pathname === '/health') { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify({ cameras: listHealth() })); - return; - } - - if (url.pathname.startsWith('/stream/')) { - const cameraId = - decodeURIComponent(url.pathname.replace('/stream/', '').trim()) || - 'camera'; - const source = sourceById.get(cameraId); - const payload = buildStreamPayload(source, cameraId); - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify(payload)); - return; - } - - if (url.pathname.startsWith('/media/')) { - const cameraId = - decodeURIComponent(url.pathname.replace('/media/', '').trim()) || - 'camera'; - const source = sourceById.get(cameraId); - const mediaUrl = source?.url || ''; - const feedType = normalizeFeedType(source?.feedType || 'image'); - - if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) { + const contentType = upstream.headers.get('content-type') || ''; + if (!upstream.ok) { setHealth(cameraId, { status: 'degraded', - sourceKind: 'fallback', - label: source?.provider || 'No upstream URL', - message: 'No stream URL configured', + sourceKind: 'upstream', + label: source?.provider || 'Configured source', + message: `Upstream HTTP ${upstream.status}`, }); - res.writeHead(404, { + res.writeHead(upstream.status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }); res.end( JSON.stringify({ - error: 'No media URL configured for this camera', + error: `Upstream returned ${upstream.status}`, }), ); return; } - try { - const upstreamHeaders = { - 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', - }; - const requestRange = req.headers?.range; - if (requestRange) upstreamHeaders.Range = requestRange; - const upstream = await fetch(mediaUrl, { - headers: upstreamHeaders, - }); - const contentType = upstream.headers.get('content-type') || ''; - if (!upstream.ok) { - setHealth(cameraId, { - status: 'degraded', - sourceKind: 'upstream', - label: source?.provider || 'Configured source', - message: `Upstream HTTP ${upstream.status}`, - }); - res.writeHead(upstream.status, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end( - JSON.stringify({ - error: `Upstream returned ${upstream.status}`, - }), - ); - return; - } - - if ( - isVideoFeedType(feedType) && - !( - contentType.startsWith('video/') || - contentType.includes('mpegurl') - ) - ) { - setHealth(cameraId, { - status: 'degraded', - sourceKind: 'upstream', - label: source?.provider || 'Configured source', - message: `Unexpected media type ${contentType || 'unknown'}`, - }); - } else { - setHealth(cameraId, { - status: 'ok', - sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', - label: source?.provider || 'Configured source', - message: isVideoFeedType(feedType) - ? 'Live stream connected' - : 'Snapshot feed connected', - }); - } - - await proxyMediaResponse(res, upstream, { - sourceHeader: isVideoFeedType(feedType) - ? 'live-media' - : 'upstream-image', - }); - return; - } catch (error) { + if ( + isVideoFeedType(feedType) && + !( + contentType.startsWith('video/') || + contentType.includes('mpegurl') + ) + ) { setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', label: source?.provider || 'Configured source', - message: error?.message || 'Media fetch failed', + message: `Unexpected media type ${contentType || 'unknown'}`, }); - res.writeHead(502, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', + } else { + setHealth(cameraId, { + status: 'ok', + sourceKind: isVideoFeedType(feedType) ? 'live' : 'snapshot', + label: source?.provider || 'Configured source', + message: isVideoFeedType(feedType) + ? 'Live stream connected' + : 'Snapshot feed connected', }); - res.end(JSON.stringify({ error: 'Media proxy failed' })); - return; } - } - if (!url.pathname.startsWith('/frame/')) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'not found' })); - return; - } - - const cameraId = - decodeURIComponent(url.pathname.replace('/frame/', '').trim()) || - 'camera'; - const source = sourceById.get(cameraId); - const label = - url.searchParams.get('label') || source?.name || cameraId; - const city = url.searchParams.get('city') || source?.city || ''; - const lat = Number(url.searchParams.get('lat') || source?.lat); - const lon = Number(url.searchParams.get('lon') || source?.lon); - const heading = Number( - url.searchParams.get('heading') || source?.headingDeg, - ); - const fov = Number(url.searchParams.get('fov') || source?.fovDeg); - const pitch = Number( - url.searchParams.get('pitch') || source?.pitchDeg, - ); - - // Only use server-registered upstream URLs — never accept client-supplied URLs - // (prevents SSRF via ?upstream= query parameter) - const upstreamCandidate = - source?.snapshotUrl || - (!isVideoFeedType(normalizeFeedType(source?.feedType)) - ? source?.url - : ''); - - const upstreamImage = - await fetchCctvImageFromUpstream(upstreamCandidate); - if (upstreamImage?.ok) { - setHealth(cameraId, { - status: 'ok', - sourceKind: 'snapshot', - label: source?.provider || 'Configured source', - message: 'Upstream snapshot active', + await proxyMediaResponse(res, upstream, { + sourceHeader: isVideoFeedType(feedType) + ? 'live-media' + : 'upstream-image', }); - res.writeHead(200, { - 'Content-Type': upstreamImage.contentType, - 'Cache-Control': 'no-store', - 'X-CCTV-Source': 'upstream-image', - }); - res.end(upstreamImage.body); return; - } - - const sv = await streetViewFallback({ - lat, - lon, - heading, - fov, - pitch, - }); - if (sv?.ok) { + } catch (error) { setHealth(cameraId, { status: 'degraded', - sourceKind: 'streetview', - label: 'Google Street View', - message: 'Fallback Street View frame', + sourceKind: 'upstream', + label: source?.provider || 'Configured source', + message: error?.message || 'Media fetch failed', }); - res.writeHead(200, { - 'Content-Type': sv.contentType, + res.writeHead(502, { + 'Content-Type': 'application/json', 'Cache-Control': 'no-store', - 'X-CCTV-Source': 'streetview', }); - res.end(sv.body); + res.end(JSON.stringify({ error: 'Media proxy failed' })); return; } + } - const svg = buildSyntheticCctvSvg({ - cameraId, - label, - city, - status: source?.url - ? 'UPSTREAM UNAVAILABLE' - : 'NO UPSTREAM CONFIGURED', + if (!url.pathname.startsWith('/frame/')) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + return; + } + + const cameraId = + decodeURIComponent(url.pathname.replace('/frame/', '').trim()) || + 'camera'; + const source = sourceById.get(cameraId); + const label = url.searchParams.get('label') || source?.name || cameraId; + const city = url.searchParams.get('city') || source?.city || ''; + const lat = Number(url.searchParams.get('lat') || source?.lat); + const lon = Number(url.searchParams.get('lon') || source?.lon); + const heading = Number( + url.searchParams.get('heading') || source?.headingDeg, + ); + const fov = Number(url.searchParams.get('fov') || source?.fovDeg); + const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg); + + // Only use server-registered upstream URLs — never accept client-supplied URLs + // (prevents SSRF via ?upstream= query parameter) + const upstreamCandidate = + source?.snapshotUrl || + (!isVideoFeedType(normalizeFeedType(source?.feedType)) + ? source?.url + : ''); + + const upstreamImage = + await fetchCctvImageFromUpstream(upstreamCandidate); + if (upstreamImage?.ok) { + setHealth(cameraId, { + status: 'ok', + sourceKind: 'snapshot', + label: source?.provider || 'Configured source', + message: 'Upstream snapshot active', }); + res.writeHead(200, { + 'Content-Type': upstreamImage.contentType, + 'Cache-Control': 'no-store', + 'X-CCTV-Source': 'upstream-image', + }); + res.end(upstreamImage.body); + return; + } + const sv = await streetViewFallback({ + lat, + lon, + heading, + fov, + pitch, + }); + if (sv?.ok) { setHealth(cameraId, { status: 'degraded', - sourceKind: 'synthetic', - label: source?.provider || 'Synthetic fallback', - message: source?.url - ? 'Upstream unavailable' - : 'No source configured', + sourceKind: 'streetview', + label: 'Google Street View', + message: 'Fallback Street View frame', }); - res.writeHead(200, { - 'Content-Type': 'image/svg+xml', + 'Content-Type': sv.contentType, 'Cache-Control': 'no-store', - 'X-CCTV-Source': 'synthetic', + 'X-CCTV-Source': 'streetview', }); - res.end(svg); - } catch (error) { - console.error('[CCTV Proxy]', error?.message || String(error)); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'CCTV proxy error' })); + res.end(sv.body); + return; } - }); - }, + + const svg = buildSyntheticCctvSvg({ + cameraId, + label, + city, + status: source?.url + ? 'UPSTREAM UNAVAILABLE' + : 'NO UPSTREAM CONFIGURED', + }); + + setHealth(cameraId, { + status: 'degraded', + sourceKind: 'synthetic', + label: source?.provider || 'Synthetic fallback', + message: source?.url + ? 'Upstream unavailable' + : 'No source configured', + }); + + res.writeHead(200, { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'no-store', + 'X-CCTV-Source': 'synthetic', + }); + res.end(svg); + } catch (error) { + console.error('[CCTV Proxy]', error?.message || String(error)); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'CCTV proxy error' })); + } + }); + }; + return { + name: 'cctv-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/firms.js b/server/providers/firms.js index afed2a20c..3a4c5ea9f 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -167,93 +167,95 @@ export function firmsProxy() { return statusInflight; } + const installMiddleware = (server) => { + server.middlewares.use('/api/firms', async (req, res) => { + const sendJson = (status, obj) => { + if (res.headersSent) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(obj)); + }; + try { + const subPath = String(req.url || '').split('?')[0]; + const key = mapKey(); + await readDiskOnce(); + + if (subPath === '/status') { + if (!key) { + sendJson(200, { + hasKey: false, + lastFetch: null, + count: null, + stale: false, + ttlMs: TTL_MS, + transactions: null, + }); + return; + } + const transactions = await getTransactions(key); + sendJson(200, { + hasKey: true, + lastFetch: mem ? mem.at : null, + count: mem ? mem.fires.length : null, + stale: mem ? Date.now() - mem.at >= TTL_MS : false, + ttlMs: TTL_MS, + transactions, + }); + return; + } + + if (!key) { + sendJson(503, { error: 'no_key' }); + return; + } + + const entry = mem; + if (entry && Date.now() - entry.at < TTL_MS) { + sendJson(200, buildPayload(entry, false)); + return; + } + // Stale or missing → refresh, single-flight (concurrent requests + // share one upstream pass). Capture the promise locally BEFORE + // awaiting: the .finally() nulls `inflight` the moment it settles. + if (!inflight) { + inflight = refreshUpstream(key) + .then(async (fresh) => { + mem = fresh; + await writeDisk(fresh); + return fresh; + }) + .catch((err) => { + console.warn( + `[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`, + ); + return null; + }) + .finally(() => { + inflight = null; + }); + } + const pending = inflight; + const fresh = await pending; + if (fresh) { + sendJson(200, buildPayload(fresh, false)); + } else if (entry) { + sendJson(200, buildPayload(entry, true)); // upstream down — stale beats empty + } else { + sendJson(502, { + error: 'firms fetch failed and no cache available', + }); + } + } catch (err) { + console.warn('[firms-proxy] error:', err?.message || err); + sendJson(500, { error: 'firms proxy error' }); + } + }); + }; return { name: 'firms-proxy', - configureServer(server) { - server.middlewares.use('/api/firms', async (req, res) => { - const sendJson = (status, obj) => { - if (res.headersSent) return; - res.writeHead(status, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify(obj)); - }; - try { - const subPath = String(req.url || '').split('?')[0]; - const key = mapKey(); - await readDiskOnce(); - - if (subPath === '/status') { - if (!key) { - sendJson(200, { - hasKey: false, - lastFetch: null, - count: null, - stale: false, - ttlMs: TTL_MS, - transactions: null, - }); - return; - } - const transactions = await getTransactions(key); - sendJson(200, { - hasKey: true, - lastFetch: mem ? mem.at : null, - count: mem ? mem.fires.length : null, - stale: mem ? Date.now() - mem.at >= TTL_MS : false, - ttlMs: TTL_MS, - transactions, - }); - return; - } - - if (!key) { - sendJson(503, { error: 'no_key' }); - return; - } - - const entry = mem; - if (entry && Date.now() - entry.at < TTL_MS) { - sendJson(200, buildPayload(entry, false)); - return; - } - // Stale or missing → refresh, single-flight (concurrent requests - // share one upstream pass). Capture the promise locally BEFORE - // awaiting: the .finally() nulls `inflight` the moment it settles. - if (!inflight) { - inflight = refreshUpstream(key) - .then(async (fresh) => { - mem = fresh; - await writeDisk(fresh); - return fresh; - }) - .catch((err) => { - console.warn( - `[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`, - ); - return null; - }) - .finally(() => { - inflight = null; - }); - } - const pending = inflight; - const fresh = await pending; - if (fresh) { - sendJson(200, buildPayload(fresh, false)); - } else if (entry) { - sendJson(200, buildPayload(entry, true)); // upstream down — stale beats empty - } else { - sendJson(502, { - error: 'firms fetch failed and no cache available', - }); - } - } catch (err) { - console.warn('[firms-proxy] error:', err?.message || err); - sendJson(500, { error: 'firms proxy error' }); - } - }); - }, + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 4e1457fce..30575b973 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -21,161 +21,163 @@ const GBFS_PROXY_TIMEOUT_MS = 12000; * @returns {import('vite').Plugin} */ export function gbfsProxy() { - return { - name: 'gbfs-proxy', - configureServer(server) { - server.middlewares.use('/api/gbfs', async (req, res) => { - try { - if (req.method !== 'GET') { - res.writeHead(405, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify({ error: 'Method Not Allowed' })); - return; - } - - const url = new URL(req.url || '/', 'http://localhost'); - const encodedTarget = url.pathname.replace(/^\/+/, ''); - if (!encodedTarget) { - res.writeHead(400, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify({ error: 'Missing GBFS upstream target' })); - return; - } - - let decodedTarget = ''; - try { - decodedTarget = decodeURIComponent(encodedTarget); - } catch { - res.writeHead(400, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' })); - return; - } - - let upstreamUrl = null; - try { - upstreamUrl = new URL(decodedTarget); - } catch { - res.writeHead(400, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' })); - return; - } - - if (upstreamUrl.protocol !== 'https:') { - res.writeHead(400, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end( - JSON.stringify({ error: 'Only https GBFS targets are allowed' }), - ); - return; - } - - if (!isAllowedGbfsHost(upstreamUrl.hostname)) { - res.writeHead(403, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify({ error: 'GBFS host not allowed' })); - return; - } - - if (!isAllowedGbfsPath(upstreamUrl.pathname)) { - res.writeHead(400, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end( - JSON.stringify({ - error: - 'Only station_information/station_status endpoints are allowed', - }), - ); - return; - } - - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - GBFS_PROXY_TIMEOUT_MS, - ); - let upstream; - try { - upstream = await fetch(upstreamUrl.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - 'User-Agent': 'gods-eye-view-gbfs-proxy/1.0', - }, - signal: controller.signal, - }); - } finally { - clearTimeout(timeoutId); - } - - // Limit response size to prevent memory exhaustion from malicious upstream - const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB - const contentLength = Number(upstream.headers.get('content-length')); - if ( - Number.isFinite(contentLength) && - contentLength > GBFS_MAX_BODY_BYTES - ) { - res.writeHead(502, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end( - JSON.stringify({ error: 'GBFS upstream response too large' }), - ); - return; - } - const body = await upstream.text(); - if (Buffer.byteLength(body, 'utf8') > GBFS_MAX_BODY_BYTES) { - res.writeHead(502, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end( - JSON.stringify({ error: 'GBFS upstream response too large' }), - ); - return; - } - const contentType = - upstream.headers.get('content-type') || 'application/json'; - res.writeHead(upstream.status, { - 'Content-Type': contentType, - 'Cache-Control': gbfsCacheControl(upstreamUrl.pathname), - 'X-GBFS-Upstream': upstreamUrl.hostname, - 'X-GBFS-Cache': 'MISS', + const installMiddleware = (server) => { + server.middlewares.use('/api/gbfs', async (req, res) => { + try { + if (req.method !== 'GET') { + res.writeHead(405, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', }); - res.end(body); - } catch (error) { - if (error?.name === 'AbortError') { - res.writeHead(504, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); - res.end(JSON.stringify({ error: 'GBFS upstream timeout' })); - return; - } - console.error('[GBFS Proxy]', error?.message || String(error)); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + + const url = new URL(req.url || '/', 'http://localhost'); + const encodedTarget = url.pathname.replace(/^\/+/, ''); + if (!encodedTarget) { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Missing GBFS upstream target' })); + return; + } + + let decodedTarget = ''; + try { + decodedTarget = decodeURIComponent(encodedTarget); + } catch { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' })); + return; + } + + let upstreamUrl = null; + try { + upstreamUrl = new URL(decodedTarget); + } catch { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' })); + return; + } + + if (upstreamUrl.protocol !== 'https:') { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ error: 'Only https GBFS targets are allowed' }), + ); + return; + } + + if (!isAllowedGbfsHost(upstreamUrl.hostname)) { + res.writeHead(403, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS host not allowed' })); + return; + } + + if (!isAllowedGbfsPath(upstreamUrl.pathname)) { + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: + 'Only station_information/station_status endpoints are allowed', + }), + ); + return; + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + GBFS_PROXY_TIMEOUT_MS, + ); + let upstream; + try { + upstream = await fetch(upstreamUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-gbfs-proxy/1.0', + }, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + // Limit response size to prevent memory exhaustion from malicious upstream + const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB + const contentLength = Number(upstream.headers.get('content-length')); + if ( + Number.isFinite(contentLength) && + contentLength > GBFS_MAX_BODY_BYTES + ) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }); - res.end(JSON.stringify({ error: 'GBFS proxy error' })); + res.end( + JSON.stringify({ error: 'GBFS upstream response too large' }), + ); + return; } - }); - }, + const body = await upstream.text(); + if (Buffer.byteLength(body, 'utf8') > GBFS_MAX_BODY_BYTES) { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ error: 'GBFS upstream response too large' }), + ); + return; + } + const contentType = + upstream.headers.get('content-type') || 'application/json'; + res.writeHead(upstream.status, { + 'Content-Type': contentType, + 'Cache-Control': gbfsCacheControl(upstreamUrl.pathname), + 'X-GBFS-Upstream': upstreamUrl.hostname, + 'X-GBFS-Cache': 'MISS', + }); + res.end(body); + } catch (error) { + if (error?.name === 'AbortError') { + res.writeHead(504, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS upstream timeout' })); + return; + } + console.error('[GBFS Proxy]', error?.message || String(error)); + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify({ error: 'GBFS proxy error' })); + } + }); + }; + return { + name: 'gbfs-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/local.js b/server/providers/local.js index 7c8cfd48d..5940ab368 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -764,9 +764,7 @@ export async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX * @returns {import('vite').Plugin} */ function overpassProxy() { - return { - name: 'overpass-proxy', - configureServer(server) { + 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). @@ -896,7 +894,11 @@ function overpassProxy() { }); installRouteMiddleware(server.middlewares); - }, + }; + return { + name: 'overpass-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 2cc9cc1f8..a76cc590d 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -66,72 +66,74 @@ export function celestrakProxy() { return { at: Date.now(), body }; } - return { - name: 'celestrak-proxy', - configureServer(server) { - server.middlewares.use('/api/celestrak', async (req, res) => { - const group = String(req.url || '') - .replace(/^\//, '') - .split('?')[0]; - if (!/^[a-z0-9-]+$/i.test(group)) { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.end('invalid group'); + const installMiddleware = (server) => { + server.middlewares.use('/api/celestrak', async (req, res) => { + const group = String(req.url || '') + .replace(/^\//, '') + .split('?')[0]; + if (!/^[a-z0-9-]+$/i.test(group)) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('invalid group'); + return; + } + const send = (status, body, cacheStatus) => { + // Guard against a double-send (e.g. a throw AFTER a response already + // went out routing into the catch's send): writeHead after headersSent + // throws "Cannot set headers after they are sent". + if (res.headersSent) return; + res.writeHead(status, { + 'Content-Type': 'text/plain', + 'x-tle-cache': cacheStatus, + }); + res.end(body); + }; + try { + const now = Date.now(); + let entry = mem.get(group); + if (!entry) { + entry = await readDisk(group); + if (entry) mem.set(group, entry); + } + if (entry && now - entry.at < TLE_TTL_MS) { + send(200, entry.body, 'HIT'); return; } - const send = (status, body, cacheStatus) => { - // Guard against a double-send (e.g. a throw AFTER a response already - // went out routing into the catch's send): writeHead after headersSent - // throws "Cannot set headers after they are sent". - if (res.headersSent) return; - res.writeHead(status, { - 'Content-Type': 'text/plain', - 'x-tle-cache': cacheStatus, - }); - res.end(body); - }; - try { - const now = Date.now(); - let entry = mem.get(group); - if (!entry) { - entry = await readDisk(group); - if (entry) mem.set(group, entry); - } - if (entry && now - entry.at < TLE_TTL_MS) { - send(200, entry.body, 'HIT'); - return; - } - // Stale or missing → refresh, single-flight per group. - if (!inflight.has(group)) { - inflight.set( - group, - fetchUpstream(group) - .then(async (fresh) => { - mem.set(group, fresh); - await writeDisk(group, fresh); - return fresh; - }) - .catch((err) => { - console.warn( - '[celestrak-proxy] refresh failed — serving cache if any', - ); - return null; - }) - .finally(() => inflight.delete(group)), - ); - } - const fresh = await inflight.get(group); - if (fresh) { - send(200, fresh.body, 'MISS'); - } else if (entry) { - send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty - } else { - send(502, 'celestrak fetch failed and no cache available', 'NONE'); - } - } catch (err) { - console.error('[celestrak-proxy] request failed'); - send(500, 'celestrak proxy error', 'ERROR'); + // Stale or missing → refresh, single-flight per group. + if (!inflight.has(group)) { + inflight.set( + group, + fetchUpstream(group) + .then(async (fresh) => { + mem.set(group, fresh); + await writeDisk(group, fresh); + return fresh; + }) + .catch((err) => { + console.warn( + '[celestrak-proxy] refresh failed — serving cache if any', + ); + return null; + }) + .finally(() => inflight.delete(group)), + ); } - }); - }, + const fresh = await inflight.get(group); + if (fresh) { + send(200, fresh.body, 'MISS'); + } else if (entry) { + send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty + } else { + send(502, 'celestrak fetch failed and no cache available', 'NONE'); + } + } catch (err) { + console.error('[celestrak-proxy] request failed'); + send(500, 'celestrak proxy error', 'ERROR'); + } + }); + }; + return { + name: 'celestrak-proxy', + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/terrain.js b/server/providers/terrain.js index 5c7e6c609..23de62f42 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -131,53 +131,55 @@ export function terrainHeightsProxy() { return inflight.get(key); } + const installMiddleware = (server) => { + server.middlewares.use('/api/terrain/heights', async (req, res) => { + const send = (status, bodyObj) => { + if (res.headersSent) return; + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(bodyObj)); + }; + try { + await loadDiskOnce(); + const parsedUrl = new URL(req.url || '', 'http://internal'); + const rawPoints = parsedUrl.searchParams.get('points'); + const points = parseTerrainPoints(rawPoints); + if (!points) { + send(400, { + error: + 'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers', + }); + return; + } + if (points.length > MAX_POINTS) { + send(500, { + error: `too many points (${points.length}); max ${MAX_POINTS} per request`, + }); + return; + } + + const outcome = await resolveTerrainHeightRequest({ + points, + cache: mem, + fetchMissing: fetchMissingSingleFlight, + ttlMs: TTL_MS, + }); + if (outcome.cacheChanged) diskDirty = true; + if (outcome.upstreamError) { + console.warn( + '[terrain-heights-proxy] refresh incomplete' + + ' — serving stale points when available', + ); + } + send(outcome.status, outcome.body); + } catch (err) { + console.error('[terrain-heights-proxy] request failed'); + send(500, { error: 'terrain heights proxy error' }); + } + }); + }; return { name: 'terrain-heights-proxy', - configureServer(server) { - server.middlewares.use('/api/terrain/heights', async (req, res) => { - const send = (status, bodyObj) => { - if (res.headersSent) return; - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(bodyObj)); - }; - try { - await loadDiskOnce(); - const parsedUrl = new URL(req.url || '', 'http://internal'); - const rawPoints = parsedUrl.searchParams.get('points'); - const points = parseTerrainPoints(rawPoints); - if (!points) { - send(400, { - error: - 'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers', - }); - return; - } - if (points.length > MAX_POINTS) { - send(500, { - error: `too many points (${points.length}); max ${MAX_POINTS} per request`, - }); - return; - } - - const outcome = await resolveTerrainHeightRequest({ - points, - cache: mem, - fetchMissing: fetchMissingSingleFlight, - ttlMs: TTL_MS, - }); - if (outcome.cacheChanged) diskDirty = true; - if (outcome.upstreamError) { - console.warn( - '[terrain-heights-proxy] refresh incomplete' + - ' — serving stale points when available', - ); - } - send(outcome.status, outcome.body); - } catch (err) { - console.error('[terrain-heights-proxy] request failed'); - send(500, { error: 'terrain heights proxy error' }); - } - }); - }, + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; } diff --git a/server/providers/traffic.js b/server/providers/traffic.js index a259ccaa3..fda118753 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -144,121 +144,123 @@ export function tomtomProxy() { return buf; } + const installMiddleware = (server) => { + server.middlewares.use('/api/tomtom', async (req, res) => { + // Sanitized responses only (proxy/security baseline): no upstream + // error details, and never echo the key or the upstream URL. + const sendJson = (status, obj, extraHeaders = {}) => { + if (res.headersSent) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...extraHeaders, + }); + res.end(JSON.stringify(obj)); + }; + const sendTile = (buf, cacheStatus) => { + if (res.headersSent) return; + res.writeHead(200, { + 'Content-Type': 'application/x-protobuf', + 'Cache-Control': 'no-store', + 'x-tomtom-cache': cacheStatus, + }); + res.end(buf); + }; + + try { + await loadBudgetOnce(); + const urlPath = String(req.url || '').split('?')[0]; + + if (urlPath === '/status') { + const hasKey = Boolean(process.env.TOMTOM_API_KEY); + const b = currentBudget(); + sendJson(200, { + hasKey, + dailyCount: b.count, + budget: dailyBudgetLimit(), + date: b.date, + }); + return; + } + + const m = urlPath.match(/^\/flow\/(\d+)\/(\d+)\/(\d+)\.pbf$/); + if (!m) { + sendJson(404, { error: 'not_found' }); + return; + } + const z = Number(m[1]); + const x = Number(m[2]); + const y = Number(m[3]); + if (!isValidTomTomTile(z, x, y)) { + sendJson(400, { error: 'invalid_tile' }); + return; + } + if (!process.env.TOMTOM_API_KEY) { + sendJson(503, { error: 'no_key' }); + return; + } + + const key = `${z}/${x}/${y}`; + const now = Date.now(); + + let entry = mem.get(key); + if (!entry) { + entry = await readDiskTile(key); + if (entry) memSet(key, entry); + } + // Fresh cache hit — never counts against the budget. + if (entry && now - entry.at < TILE_TTL_MS) { + sendTile(entry.buf, 'HIT'); + return; + } + + // Budget governor: over the soft cap, last-good data beats a dead layer. + if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) { + if (entry) { + sendTile(entry.buf, 'STALE-BUDGET'); + } else { + sendJson(429, { error: 'budget' }); + } + return; + } + + // Stale or missing → refresh, single-flight per tile. + if (!inflight.has(key)) { + inflight.set( + key, + fetchUpstream(z, x, y) + .then(async (buf) => { + const fresh = { at: Date.now(), buf }; + memSet(key, fresh); + await writeDiskTile(key, buf); + return fresh; + }) + .catch((err) => { + console.warn( + `[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`, + ); + return null; + }) + .finally(() => inflight.delete(key)), + ); + } + const fresh = await inflight.get(key); + if (fresh) { + sendTile(fresh.buf, 'MISS'); + } else if (entry) { + sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty + } else { + sendJson(502, { error: 'upstream' }); + } + } catch (err) { + console.warn('[tomtom-proxy] error:', err?.message || err); + sendJson(500, { error: 'proxy' }); + } + }); + }; return { name: 'tomtom-proxy', - configureServer(server) { - server.middlewares.use('/api/tomtom', async (req, res) => { - // Sanitized responses only (proxy/security baseline): no upstream - // error details, and never echo the key or the upstream URL. - const sendJson = (status, obj, extraHeaders = {}) => { - if (res.headersSent) return; - res.writeHead(status, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - ...extraHeaders, - }); - res.end(JSON.stringify(obj)); - }; - const sendTile = (buf, cacheStatus) => { - if (res.headersSent) return; - res.writeHead(200, { - 'Content-Type': 'application/x-protobuf', - 'Cache-Control': 'no-store', - 'x-tomtom-cache': cacheStatus, - }); - res.end(buf); - }; - - try { - await loadBudgetOnce(); - const urlPath = String(req.url || '').split('?')[0]; - - if (urlPath === '/status') { - const hasKey = Boolean(process.env.TOMTOM_API_KEY); - const b = currentBudget(); - sendJson(200, { - hasKey, - dailyCount: b.count, - budget: dailyBudgetLimit(), - date: b.date, - }); - return; - } - - const m = urlPath.match(/^\/flow\/(\d+)\/(\d+)\/(\d+)\.pbf$/); - if (!m) { - sendJson(404, { error: 'not_found' }); - return; - } - const z = Number(m[1]); - const x = Number(m[2]); - const y = Number(m[3]); - if (!isValidTomTomTile(z, x, y)) { - sendJson(400, { error: 'invalid_tile' }); - return; - } - if (!process.env.TOMTOM_API_KEY) { - sendJson(503, { error: 'no_key' }); - return; - } - - const key = `${z}/${x}/${y}`; - const now = Date.now(); - - let entry = mem.get(key); - if (!entry) { - entry = await readDiskTile(key); - if (entry) memSet(key, entry); - } - // Fresh cache hit — never counts against the budget. - if (entry && now - entry.at < TILE_TTL_MS) { - sendTile(entry.buf, 'HIT'); - return; - } - - // Budget governor: over the soft cap, last-good data beats a dead layer. - if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) { - if (entry) { - sendTile(entry.buf, 'STALE-BUDGET'); - } else { - sendJson(429, { error: 'budget' }); - } - return; - } - - // Stale or missing → refresh, single-flight per tile. - if (!inflight.has(key)) { - inflight.set( - key, - fetchUpstream(z, x, y) - .then(async (buf) => { - const fresh = { at: Date.now(), buf }; - memSet(key, fresh); - await writeDiskTile(key, buf); - return fresh; - }) - .catch((err) => { - console.warn( - `[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`, - ); - return null; - }) - .finally(() => inflight.delete(key)), - ); - } - const fresh = await inflight.get(key); - if (fresh) { - sendTile(fresh.buf, 'MISS'); - } else if (entry) { - sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty - } else { - sendJson(502, { error: 'upstream' }); - } - } catch (err) { - console.warn('[tomtom-proxy] error:', err?.message || err); - sendJson(500, { error: 'proxy' }); - } - }); - }, + configureServer: installMiddleware, + configurePreviewServer: installMiddleware, }; }