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
+104 -9
View File
@@ -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);
+12 -6
View File
@@ -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) {
+12 -13
View File
@@ -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, {
+32 -22
View File
@@ -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 {
+7
View File
@@ -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');