mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Node always sets statusCode on client responses, but keeping the fallback stops a missing status from reading as 200, as main did. Co-Authored-By: Claude Opus 5.5 <[email protected]>
163 lines
5.0 KiB
JavaScript
163 lines
5.0 KiB
JavaScript
import https from 'node:https';
|
|
import { Readable } from 'node:stream';
|
|
import { isNonGlobalIpv4 } from './stations.js';
|
|
|
|
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
|
|
export function radioMirrorOrigin(value) {
|
|
const hostname = String(value ?? '')
|
|
.toLowerCase()
|
|
.replace(/\.$/, '');
|
|
if (!/^[a-z0-9-]+\.api\.radio-browser\.info$/.test(hostname)) return null;
|
|
return `https://${hostname}`;
|
|
}
|
|
|
|
/** Return whether a resolved Radio Browser address is safe for an outbound request. */
|
|
export function isPublicRadioAddress(value) {
|
|
const address = String(value ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^\[|\]$/g, '');
|
|
if (!address) return false;
|
|
if (!address.includes(':')) {
|
|
const ipv4 = address.split('.');
|
|
return (
|
|
ipv4.length === 4 &&
|
|
ipv4.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) &&
|
|
!isNonGlobalIpv4(address)
|
|
);
|
|
}
|
|
const pieces = address.split('::');
|
|
if (pieces.length > 2) return false;
|
|
const left = pieces[0] ? pieces[0].split(':') : [];
|
|
const right = pieces[1] ? pieces[1].split(':') : [];
|
|
const missing = 8 - left.length - right.length;
|
|
if (
|
|
(pieces.length === 1 && missing !== 0) ||
|
|
(pieces.length === 2 && missing < 1)
|
|
)
|
|
return false;
|
|
const groups = [...left, ...Array(Math.max(0, missing)).fill('0'), ...right];
|
|
if (
|
|
groups.length !== 8 ||
|
|
groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))
|
|
)
|
|
return false;
|
|
const numeric = groups.reduce(
|
|
(total, group) => (total << 16n) | BigInt(`0x${group}`),
|
|
0n,
|
|
);
|
|
const inCidr = (base, prefix) => {
|
|
const shift = 128n - BigInt(prefix);
|
|
return numeric >> shift === base >> shift;
|
|
};
|
|
const base = (text) =>
|
|
text
|
|
.split(':')
|
|
.reduce(
|
|
(total, group) => (total << 16n) | BigInt(`0x${group || '0'}`),
|
|
0n,
|
|
);
|
|
const cidr = (text, prefix) => inCidr(base(text), prefix);
|
|
return (
|
|
cidr('2000:0:0:0:0:0:0:0', 3) &&
|
|
!cidr('2001:0:0:0:0:0:0:0', 23) &&
|
|
!cidr('2001:db8:0:0:0:0:0:0', 32) &&
|
|
!cidr('2002:0:0:0:0:0:0:0', 16) &&
|
|
!cidr('3fff:0:0:0:0:0:0:0', 20)
|
|
);
|
|
}
|
|
|
|
export function radioProxyDestination(value) {
|
|
let url;
|
|
try {
|
|
url = new URL(String(value));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const origin = radioMirrorOrigin(url.hostname);
|
|
if (
|
|
!origin ||
|
|
url.origin !== origin ||
|
|
url.username ||
|
|
url.password ||
|
|
url.port ||
|
|
url.hash
|
|
)
|
|
return null;
|
|
const discovery =
|
|
url.hostname.toLowerCase() === 'all.api.radio-browser.info' &&
|
|
url.pathname === '/json/servers' &&
|
|
!url.search;
|
|
const directory = url.pathname === '/json/stations/search';
|
|
const click = /^\/json\/url\/[0-9a-f-]+$/i.test(url.pathname) && !url.search;
|
|
return discovery || directory || click ? url : null;
|
|
}
|
|
|
|
export async function resolveRadioProxyAddresses(hostname, lookupImpl) {
|
|
const resolved = await lookupImpl(hostname, { all: true, verbatim: true });
|
|
const rows = Array.isArray(resolved) ? resolved : [resolved];
|
|
const addresses = rows
|
|
.map((row) => ({
|
|
address: String(row?.address || ''),
|
|
family: Number(row?.family) || undefined,
|
|
}))
|
|
.filter((row) => row.address);
|
|
if (
|
|
!addresses.length ||
|
|
addresses.some((row) => !isPublicRadioAddress(row.address))
|
|
) {
|
|
throw new Error('Radio Browser resolved to a forbidden address');
|
|
}
|
|
return addresses;
|
|
}
|
|
|
|
export function fetchPinnedRadioResponse(url, options, addresses) {
|
|
return new Promise((resolve, reject) => {
|
|
const address = addresses[0];
|
|
const request = https.request(
|
|
url,
|
|
{
|
|
method: 'GET',
|
|
headers: options.headers,
|
|
signal: options.signal,
|
|
lookup(_hostname, lookupOptions, callback) {
|
|
if (lookupOptions?.all) callback(null, addresses);
|
|
else callback(null, address.address, address.family);
|
|
},
|
|
},
|
|
(response) => {
|
|
// Nothing thrown here may escape: this callback runs outside the
|
|
// promise, so an exception would be uncaught and crash the server.
|
|
try {
|
|
const status = response.statusCode || 500;
|
|
// A `Response` only represents statuses up to 599; Node never hands
|
|
// this callback a 1xx.
|
|
if (status > 599)
|
|
throw new Error(
|
|
`Radio Browser returned unsupported status ${status}`,
|
|
);
|
|
const headers = new Headers();
|
|
for (const [name, value] of Object.entries(response.headers)) {
|
|
if (Array.isArray(value))
|
|
value.forEach((item) => headers.append(name, item));
|
|
else if (value !== undefined) headers.set(name, String(value));
|
|
}
|
|
const nullBody = NULL_BODY_STATUSES.has(status);
|
|
const upstream = new Response(
|
|
nullBody ? null : Readable.toWeb(response),
|
|
{ status, statusText: response.statusMessage || '', headers },
|
|
);
|
|
if (nullBody) response.resume();
|
|
resolve(upstream);
|
|
} catch (error) {
|
|
response.destroy();
|
|
reject(error);
|
|
}
|
|
},
|
|
);
|
|
request.on('error', reject);
|
|
request.end();
|
|
});
|
|
}
|