Merge branch 'pr-213' into dev
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export const maxDuration = 60; // 设置最大执行时间为 60 秒
|
||||
|
||||
/**
|
||||
* M3U8 代理接口
|
||||
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
|
||||
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const m3u8Url = searchParams.get('url');
|
||||
@@ -34,12 +37,40 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||
// 安全校验:防 SSRF / 域名重绑定,只允许合法的公网 URL。对所有经过 proxy-m3u8 的请求强制校验,不仅限于 directplay
|
||||
const isSafeUrl = await validateProxyUrlServerSide(m3u8Url);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Proxy request to local or invalid network is forbidden' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取当前请求的 origin
|
||||
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||||
let origin = process.env.SITE_BASE;
|
||||
if (!origin) {
|
||||
const requestUrl = new URL(request.url);
|
||||
origin = `${requestUrl.protocol}//${requestUrl.host}`;
|
||||
// 从请求头中获取 Host 和协议
|
||||
let host = request.headers.get('host') || request.headers.get('x-forwarded-host');
|
||||
|
||||
// 安全校验:防 Host 头注入漏洞 (要求仅包含合法域名或 IP 格式字符)
|
||||
if (host && !/^[a-zA-Z0-9.-]+(:\d+)?$/.test(host)) {
|
||||
host = null;
|
||||
}
|
||||
|
||||
// Fallback:如果以上 Header 无效或未提供,回退到 request.url 获取
|
||||
if (!host) {
|
||||
try {
|
||||
host = new URL(request.url).host;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid Request Host' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const proto = request.headers.get('x-forwarded-proto') ||
|
||||
(host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https');
|
||||
origin = `${proto}://${host}`;
|
||||
}
|
||||
|
||||
// 获取原始 m3u8 内容
|
||||
@@ -61,8 +92,58 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// 后端 MIME Sniffing: 防御伪装成 m3u8 的大文件二进制流
|
||||
// 使用白名单策略:只有明确属于文本/m3u8 类型的才放行解析
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
const isTextType = (
|
||||
contentType === '' || // 无 Content-Type 时保守放行(后续有内容校验兜底)
|
||||
contentType.includes('application/vnd.apple.mpegurl') || // 标准 m3u8
|
||||
contentType.includes('application/x-mpegurl') || // 兼容 m3u8
|
||||
contentType.includes('audio/mpegurl') || // 兼容 m3u8
|
||||
contentType.includes('text/') || // text/plain 等
|
||||
contentType.includes('application/json') // 部分 API 返回 JSON 格式的错误
|
||||
);
|
||||
|
||||
if (!isTextType) {
|
||||
if (source === DIRECT_PLAY_SOURCE) {
|
||||
console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`);
|
||||
// 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header
|
||||
const newHeaders = new Headers(response.headers);
|
||||
newHeaders.set('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// 如果源站返回了跨站相关的禁止头,尽量移除它们
|
||||
newHeaders.delete('X-Frame-Options');
|
||||
newHeaders.delete('Content-Security-Policy');
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
console.warn(`[Proxy-M3U8] 拦截到非文本媒体流 (Content-Type: ${contentType}), 拒绝按文本解析, URL: ${m3u8Url}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unsupported Media Type',
|
||||
details: `The source returned Content-Type "${contentType}", which is not a text m3u8 playlist.`,
|
||||
fallbackToDirect: true,
|
||||
originalUrl: m3u8Url
|
||||
},
|
||||
{ status: 415, headers: { 'Access-Control-Allow-Origin': '*' } }
|
||||
);
|
||||
}
|
||||
|
||||
let m3u8Content = await response.text();
|
||||
|
||||
// 二次内容校验:即使 Content-Type 通过了白名单,检查实际内容是否为有效的 m3u8
|
||||
// 有些服务器返回 text/plain 但实际内容是 HTML 错误页或其他格式
|
||||
const trimmedContent = m3u8Content.trimStart();
|
||||
if (trimmedContent.length > 0 && !trimmedContent.startsWith('#EXTM3U') && !trimmedContent.startsWith('#EXT')) {
|
||||
console.warn(`[Proxy-M3U8] 内容校验失败:响应体不以 #EXTM3U 或 #EXT 开头, 可能非有效 m3u8, URL: ${m3u8Url}`);
|
||||
// 不直接拒绝(可能是不规范但仍可播放的 m3u8),仅打印警告继续处理
|
||||
}
|
||||
|
||||
// 执行去广告逻辑
|
||||
const config = await getConfig();
|
||||
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
|
||||
@@ -111,7 +192,10 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认去广告规则
|
||||
* 默认去广告规则(服务端版本)
|
||||
* 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。
|
||||
* 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。
|
||||
* 两套逻辑需要保持同步更新。
|
||||
*/
|
||||
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
||||
if (!m3u8Content) return '';
|
||||
@@ -167,7 +251,10 @@ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接
|
||||
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接。
|
||||
* 此函数仅在代理模式下由服务端调用。
|
||||
* - 子 m3u8 链接 → 指向 /api/proxy-m3u8(递归代理)
|
||||
* - ts 分片/密钥 → directplay 模式指向 /api/proxy/vod/segment(解决 CORS)
|
||||
*/
|
||||
function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string {
|
||||
const lines = m3u8Content.split('\n');
|
||||
@@ -196,10 +283,15 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
||||
} else {
|
||||
keyUri = new URL(keyUri, baseDir).href;
|
||||
}
|
||||
|
||||
// 替换原来的 URI
|
||||
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
|
||||
}
|
||||
|
||||
// 直链播放模式:通过代理访问密钥,避免 CORS 问题
|
||||
if (source === 'directplay') {
|
||||
keyUri = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(keyUri)}&source=directplay`;
|
||||
}
|
||||
|
||||
// 替换原来的 URI
|
||||
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
|
||||
}
|
||||
resolvedLines.push(line);
|
||||
continue;
|
||||
@@ -240,6 +332,9 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
||||
if (isM3u8) {
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
||||
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
|
||||
} else if (source === 'directplay') {
|
||||
// 直链播放模式:通过代理访问媒体分片(ts/jpeg/png 等),避免 CORS 问题
|
||||
url = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(url)}&source=directplay`;
|
||||
}
|
||||
|
||||
resolvedLines.push(url);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -33,6 +35,13 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
// 安全校验:防 SSRF 拦截请求内网或非法 URL
|
||||
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
@@ -44,12 +53,9 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
|
||||
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('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
const headers = buildProxyStreamHeaders(
|
||||
response.headers.get('Content-Type') || 'application/octet-stream'
|
||||
);
|
||||
|
||||
return new Response(response.body, { headers });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { getBaseUrl, resolveUrl } from "@/lib/live";
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
import { buildProxyM3u8Headers, buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -38,6 +40,12 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
// 安全校验:防 SSRF 拦截请求内网或非法 URL
|
||||
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
response = await fetch(decodedUrl, {
|
||||
cache: 'no-cache',
|
||||
redirect: 'follow',
|
||||
@@ -66,23 +74,14 @@ export async function GET(request: Request) {
|
||||
// 重写 M3U8 内容
|
||||
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
|
||||
|
||||
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');
|
||||
const headers = buildProxyM3u8Headers(contentType || undefined);
|
||||
return new Response(modifiedContent, { headers });
|
||||
}
|
||||
// just proxy
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || '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');
|
||||
const headers = buildProxyStreamHeaders(
|
||||
response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'
|
||||
);
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
|
||||
// 直接返回视频流
|
||||
return new Response(response.body, {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -19,16 +21,22 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查该视频源是否启用了代理模式
|
||||
const config = await getConfig();
|
||||
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||
// 定义直链播放模式常量
|
||||
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||
|
||||
if (!videoSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
// 直链播放模式:跳过源站配置检查,直接代理
|
||||
if (source !== DIRECT_PLAY_SOURCE) {
|
||||
// 检查该视频源是否启用了代理模式
|
||||
const config = await getConfig();
|
||||
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||
|
||||
if (!videoSource.proxyMode) {
|
||||
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||
if (!videoSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!videoSource.proxyMode) {
|
||||
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response | null = null;
|
||||
@@ -36,6 +44,13 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
// 安全校验:防 SSRF 拦截请求内网或非法 URL (强制检查所有代理请求)
|
||||
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
@@ -46,19 +61,14 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t');
|
||||
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');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
const headers = buildProxyStreamHeaders(
|
||||
response.headers.get('Content-Type') || 'video/mp2t',
|
||||
response.headers.get('content-length')
|
||||
);
|
||||
|
||||
// 使用流式传输,避免占用内存
|
||||
let isCancelled = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
if (!response?.body) {
|
||||
@@ -67,7 +77,6 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
reader = response.body.getReader();
|
||||
const isCancelled = false;
|
||||
|
||||
function pump() {
|
||||
if (isCancelled || !reader) {
|
||||
@@ -109,6 +118,7 @@ export async function GET(request: Request) {
|
||||
pump();
|
||||
},
|
||||
cancel() {
|
||||
isCancelled = true;
|
||||
// 当流被取消时,确保释放所有资源
|
||||
if (reader) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -11,6 +12,12 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing video URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 安全校验:防 SSRF,只允许合法的公网 URL
|
||||
const isSafeUrl = await validateProxyUrlServerSide(videoUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取客户端的Range请求头
|
||||
const range = request.headers.get('range');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
39
src/lib/server/proxy-headers.ts
Normal file
39
src/lib/server/proxy-headers.ts
Normal 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
src/lib/server/ssrf.ts
Normal file
94
src/lib/server/ssrf.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
src/lib/utils.ts
126
src/lib/utils.ts
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user