fix: register data providers for local preview

Adapt the preview middleware contribution from PR #324 to the extracted provider modules, including the existing OpenSky route.
This commit is contained in:
Jejugo authored and Sameh Khamis committed 2026-09-12 12:43:54 -07:00
1 parent 276bb84904
commit a0f07b752f
10 files changed
+1100 -1089

No files matched your search

+42 -40
View File
@@ -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,
};
}
+34 -38
View File
@@ -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,
};
}
+321 -319
View File
@@ -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,
};
}
+227 -228
View File
@@ -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,
};
}
+88 -86
View File
@@ -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,
};
}
+152 -150
View File
@@ -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,
};
}
+6 -4
View File
@@ -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,
};
}
+66 -64
View File
@@ -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,
};
}
+48 -46
View File
@@ -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,
};
}
+116 -114
View File
@@ -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,
};
}