mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
refactor: split terrain traffic fire and bike-share providers
This commit is contained in:
1 parent
3ec3fe6822
commit
f4054469d5
13 files changed
+1024
-797
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath, gbfsCacheControl } from '../../src/data/gbfsSource.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GBFS (General Bikeshare Feed Specification) proxy constants
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Upstream fetch timeout for GBFS requests (ms). */
|
||||
const GBFS_PROXY_TIMEOUT_MS = 12000;
|
||||
|
||||
/**
|
||||
* Vite plugin: GBFS bike-share proxy with host allowlisting and size limits.
|
||||
*
|
||||
* Accepts GET /api/gbfs/<encoded-upstream-URL> and proxies the request
|
||||
* to the upstream GBFS provider. Validates hostname against an allowlist,
|
||||
* restricts to station_information/station_status paths, enforces HTTPS,
|
||||
* and caps response body at 5 MB.
|
||||
*
|
||||
* @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',
|
||||
});
|
||||
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' }));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user