diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts index 6d52a03..58300a0 100644 --- a/src/app/api/proxy-m3u8/route.ts +++ b/src/app/api/proxy-m3u8/route.ts @@ -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); diff --git a/src/app/api/proxy/vod/key/route.ts b/src/app/api/proxy/vod/key/route.ts index eb7762d..8223e51 100644 --- a/src/app/api/proxy/vod/key/route.ts +++ b/src/app/api/proxy/vod/key/route.ts @@ -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) { diff --git a/src/app/api/proxy/vod/m3u8/route.ts b/src/app/api/proxy/vod/m3u8/route.ts index 9e3590e..b1fab92 100644 --- a/src/app/api/proxy/vod/m3u8/route.ts +++ b/src/app/api/proxy/vod/m3u8/route.ts @@ -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, { diff --git a/src/app/api/proxy/vod/segment/route.ts b/src/app/api/proxy/vod/segment/route.ts index 8b8a734..4682d2e 100644 --- a/src/app/api/proxy/vod/segment/route.ts +++ b/src/app/api/proxy/vod/segment/route.ts @@ -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 { diff --git a/src/app/api/video-proxy/route.ts b/src/app/api/video-proxy/route.ts index ddf85e1..7df8bb8 100644 --- a/src/app/api/video-proxy/route.ts +++ b/src/app/api/video-proxy/route.ts @@ -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'); diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c76c1eb..ec0f0da 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2,7 +2,7 @@ 'use client'; -import { AlertCircle,Cloud, Heart, Sparkles, X } from 'lucide-react'; +import { AlertCircle, Cloud, Heart, Sparkles, X } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; @@ -29,7 +29,7 @@ import { saveDanmakuSourceIndex, saveManualDanmakuSelection, } from '@/lib/danmaku/selection-memory'; -import type { DanmakuAnime, DanmakuComment,DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types'; +import type { DanmakuAnime, DanmakuComment, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types'; import { deleteFavorite, deletePlayRecord, @@ -47,7 +47,7 @@ import { } from '@/lib/db.client'; import { getDoubanDetail } from '@/lib/douban.client'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; -import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types'; +import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { useEnableComments } from '@/hooks/useEnableComments'; import { usePlaySync } from '@/hooks/usePlaySync'; @@ -1255,7 +1255,7 @@ function PlayPageClient() { const [videoUrl, setVideoUrl] = useState(''); // 视频清晰度列表 - const [videoQualities, setVideoQualities] = useState>([]); + const [videoQualities, setVideoQualities] = useState>([]); // Xiaoya链接刷新相关状态 const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接 @@ -1335,6 +1335,30 @@ function PlayPageClient() { 'initing' | 'sourceChanging' >('initing'); const [videoError, setVideoError] = useState(null); + // 直链播放时 CORS 失败的原始 URL,用于显示"使用代理播放"按钮 + const [corsFailedUrl, setCorsFailedUrl] = useState(null); + // 标记当前视频是否已经尝试过代理(防止 415→直连→失败→代理 的无限循环) + const proxyAttemptedRef = useRef(false); + + // 直链代理域名记忆:检查某个域名是否需要代理 + const isDirectplayDomainProxied = (url: string): boolean => { + try { + const domain = new URL(url).hostname; + const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]'); + return domains.includes(domain); + } catch { return false; } + }; + // 将域名记录到代理列表 + const addDirectplayProxyDomain = (url: string) => { + try { + const domain = new URL(url).hostname; + const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]'); + if (!domains.includes(domain)) { + domains.push(domain); + localStorage.setItem('directplay_proxy_domains', JSON.stringify(domains)); + } + } catch { /* ignore */ } + }; // 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置) const [playerReady, setPlayerReady] = useState(false); @@ -1415,11 +1439,31 @@ function PlayPageClient() { } // 获取当前集数的播放地址 - const episodeUrl = detail.episodes[currentEpisodeIndex]; + let episodeUrl = detail.episodes[currentEpisodeIndex]; if (!episodeUrl) { return; } + // 简单的正则或者后缀判断,如果明确不是 m3u8 (比如 mp4),则不走 m3u8 代理 + const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/); + + if (currentSource === 'directplay' && isM3u8) { + // 仅当 localStorage 记忆了该域名需要代理时才走代理 + if (isDirectplayDomainProxied(episodeUrl)) { + const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; + episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`; + } else { + // 直链模式且未走代理:跳过 HLS.js 探测。 + // getVideoResolutionFromM3u8 内部使用 HLS.js (XMLHttpRequest) 加载, + // 而 XHR 受 CORS 限制,探测必然失败。实际播放器通过