Merge branch 'pr-213' into dev

This commit is contained in:
mtvpls
2026-03-12 20:10:41 +08:00
9 changed files with 2277 additions and 1889 deletions
+39
View File
@@ -0,0 +1,39 @@
/**
* 代理接口共享工具函数
* 用于构建标准化的 CORS 响应头,避免各代理路由中的重复代码。
*/
/**
* 构建标准的代理流式响应头 (用于 ts 分片、密钥、二进制流等)
* 包含 CORS、Accept-Ranges 和 Content-Length 等标准头。
*/
export function buildProxyStreamHeaders(
contentType: string,
contentLength?: string | null
): Headers {
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Accept-Ranges', 'bytes');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
return headers;
}
/**
* 构建标准的代理 M3U8 播放列表响应头
*/
export function buildProxyM3u8Headers(contentType?: string): Headers {
const headers = new Headers();
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
return headers;
}
+94
View File
@@ -0,0 +1,94 @@
import dns from 'dns';
/**
* 判断 IP 地址是否为内网/本地私有地址
* 覆盖 IPv4 和 IPv6,彻底杜绝所有变体绕过。
*/
export function isPrivateIP(ip: string): boolean {
// IPv4 私有地址和环回地址
if (ip.includes('.')) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4) return false;
return (
parts[0] === 10 || // 10.x.x.x
parts[0] === 127 || // 127.x.x.x (Loopback)
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.x.x - 172.31.x.x
(parts[0] === 192 && parts[1] === 168) || // 192.168.x.x
(parts[0] === 169 && parts[1] === 254) || // 169.254.x.x (Link-local)
parts[0] === 0 // 0.x.x.x ("This network")
);
}
// IPv6 私有地址和环回地址
if (ip.includes(':')) {
// ::1 环回地址 (Loopback)
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true;
// 0::0 / :: 未指定地址
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true;
const lowerIp = ip.toLowerCase();
// IPv4 映射到 IPv6 的地址 (例如 ::ffff:127.0.0.1)
if (lowerIp.startsWith('::ffff:')) {
return isPrivateIP(lowerIp.substring(7));
}
// 唯一本地地址 (Unique Local Addresses, fc00::/7)
if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) return true;
// 链路本地地址 (Link-Local Addresses, fe80::/10)
if (lowerIp.startsWith('fe8') || lowerIp.startsWith('fe9') || lowerIp.startsWith('fea') || lowerIp.startsWith('feb')) return true;
}
return false;
}
/**
* 校验代理 URL 是否安全 (防止 SSRF / DNS 重绑定漏洞)
* 只在 Node.js 服务端运行。
*/
export async function validateProxyUrlServerSide(urlStr: string): Promise<boolean> {
if (!urlStr) return false;
try {
const parsed = new URL(urlStr);
// 1. 协议检查
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
// 2. 剥离认证信息防止混淆
if (parsed.username || parsed.password) {
return false;
}
let { hostname } = parsed;
// 清洗 IPv6 括号边界
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.substring(1, hostname.length - 1);
}
// 3. DNS 真实解析 (获取底层物理 IP)
// 这一步能彻底打碎各种形式的短格式 IP (127.1)、八/十六进制 IP (0x7f.0.0.1)、或者指向 127.0.0.1 的恶意外部域名 DNS 重绑定。
const lookupResult = await dns.promises.lookup(hostname);
if (!lookupResult || !lookupResult.address) {
return false; // 解析不出 IP 则拒绝
}
// 4. 对物理 IP 进行内网校验
if (isPrivateIP(lookupResult.address)) {
console.warn(`[SSRF 防护] 拦截到尝试访问内部网络的请求 URL: ${urlStr} (解析出的底层 IP: ${lookupResult.address})`);
return false;
}
return true;
} catch (error) {
// 凡是报错(无论是 URL 解析失败,还是 DNS 解析失败,还是域名不存在),均作为不安全拒绝
console.warn(`[SSRF 防护] URL解析失败或不合法, 拒绝代理请求: ${urlStr}`);
return false;
}
}
+68 -58
View File
@@ -155,7 +155,7 @@ export function processVideoUrl(originalUrl: string): string {
*/
export async function getVideoResolutionFromM3u8(
m3u8Url: string,
timeoutMs = 4000
timeoutMs = 6000
): Promise<{
quality: string; // 如720p、1080p等
loadSpeed: string; // 自动转换为KB/s或MB/s
@@ -185,11 +185,55 @@ export async function getVideoResolutionFromM3u8(
// 固定使用hls.js加载
const hls = new Hls();
// 设置超时处理 - 使用传入的超时时间
const timeout = setTimeout(() => {
let actualLoadSpeed = '未知';
let hasSpeedCalculated = false;
let hasMetadataLoaded = false;
let estimatedBitrate = 0; // 估算的码率(bps
// 提取核心返回逻辑供 resolve 和 timeout 共同调用
const resolveCurrentState = () => {
const width = video.videoWidth;
const quality =
width >= 3840
? '4K'
: width >= 2560
? '2K'
: width >= 1920
? '1080p'
: width >= 1280
? '720p'
: width >= 854
? '480p'
: width > 0
? 'SD'
: '未知';
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
hls.destroy();
video.remove();
reject(new Error('Timeout loading video metadata'));
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
};
// 设置超时处理 - 如果部分数据已拿到,则宽容返回
const timeout = setTimeout(() => {
if (hasMetadataLoaded || hasSpeedCalculated) {
resolveCurrentState();
} else {
hls.destroy();
video.remove();
reject(new Error('Timeout loading video metadata'));
}
}, timeoutMs);
video.onerror = () => {
@@ -199,67 +243,16 @@ export async function getVideoResolutionFromM3u8(
reject(new Error('Failed to load video metadata'));
};
let actualLoadSpeed = '未知';
let hasSpeedCalculated = false;
let hasMetadataLoaded = false;
let estimatedBitrate = 0; // 估算的码率(bps
let fragmentStartTime = 0;
// 检查是否可以返回结果
// 检查是否可以相互满足要求
const checkAndResolve = () => {
if (
hasMetadataLoaded &&
(hasSpeedCalculated || actualLoadSpeed !== '未知')
) {
clearTimeout(timeout);
const width = video.videoWidth;
if (width && width > 0) {
hls.destroy();
video.remove();
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
const quality =
width >= 3840
? '4K' // 4K: 3840x2160
: width >= 2560
? '2K' // 2K: 2560x1440
: width >= 1920
? '1080p' // 1080p: 1920x1080
: width >= 1280
? '720p' // 720p: 1280x720
: width >= 854
? '480p'
: 'SD'; // 480p: 854x480
// 格式化码率
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
} else {
// webkit 无法获取尺寸,直接返回
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality: '未知',
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
}
resolveCurrentState();
}
};
@@ -309,7 +302,7 @@ export async function getVideoResolutionFromM3u8(
});
// 为分片请求添加时间戳参数破除浏览器缓存
hls.config.xhrSetup = function(xhr: XMLHttpRequest, url: string) {
hls.config.xhrSetup = function (xhr: XMLHttpRequest, url: string) {
const urlWithTimestamp = url.includes('?')
? `${url}&_t=${Date.now()}`
: `${url}?_t=${Date.now()}`;
@@ -323,6 +316,22 @@ export async function getVideoResolutionFromM3u8(
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
console.error('HLS错误:', data);
if (data.fatal) {
const statusCode = data.response?.code || data.response?.status;
// 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除
if (statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8'))) {
console.log('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过');
clearTimeout(timeout);
hls.destroy();
video.remove();
resolve({
quality: '原生画质',
loadSpeed: '直连',
pingTime: 10,
bitrate: '未知',
});
return;
}
clearTimeout(timeout);
hls.destroy();
video.remove();
@@ -397,3 +406,4 @@ export function base58Decode(encoded: string): string {
// 在 Node.js 环境中使用 Buffer
return Buffer.from(bytes).toString('utf-8');
}