Files
gods-eye-view/server/providers/radio/transport.js
T
Said TurkseverandClaude Opus 5.5 3533b323b0 fix(radio): keep the || 500 fallback for a missing mirror status
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]>
2026-10-15 14:28:37 +01:00

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();
});
}