mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Adapt the preview middleware contribution from PR #324 to the extracted provider modules, including the existing OpenSky route.
184 lines
5.8 KiB
JavaScript
184 lines
5.8 KiB
JavaScript
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() {
|
|
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(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' }));
|
|
}
|
|
});
|
|
};
|
|
return {
|
|
name: 'gbfs-proxy',
|
|
configureServer: installMiddleware,
|
|
configurePreviewServer: installMiddleware,
|
|
};
|
|
}
|