// /netlify/functions/proxy.mjs - Netlify Function (ES Module) import fetch from 'node-fetch'; import { URL } from 'url'; // Use Node.js built-in URL // --- Configuration (Read from Environment Variables) --- const DEBUG_ENABLED = process.env.DEBUG === 'true'; const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // Default 24 hours const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // Default 5 levels // --- User Agent Handling --- let USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' ]; try { const agentsJsonString = process.env.USER_AGENTS_JSON; if (agentsJsonString) { const parsedAgents = JSON.parse(agentsJsonString); if (Array.isArray(parsedAgents) && parsedAgents.length > 0) { USER_AGENTS = parsedAgents; console.log(`[Proxy Log Netlify] Loaded ${USER_AGENTS.length} user agents from environment variable.`); } else { console.warn("[Proxy Log Netlify] USER_AGENTS_JSON environment variable is not a valid non-empty array, using default."); } } else { console.log("[Proxy Log Netlify] USER_AGENTS_JSON environment variable not set, using default user agents."); } } catch (e) { console.error(`[Proxy Log Netlify] Error parsing USER_AGENTS_JSON environment variable: ${e.message}. Using default user agents.`); } const FILTER_DISCONTINUITY = false; // Ad filtering disabled // --- Helper Functions (Same as Vercel version, except rewriteUrlToProxy) --- function logDebug(message) { if (DEBUG_ENABLED) { console.log(`[Proxy Log Netlify] ${message}`); } } function getTargetUrlFromPath(encodedPath) { if (!encodedPath) { logDebug("getTargetUrlFromPath received empty path."); return null; } try { const decodedUrl = decodeURIComponent(encodedPath); if (decodedUrl.match(/^https?:\/\/.+/i)) { return decodedUrl; } else { logDebug(`Invalid decoded URL format: ${decodedUrl}`); if (encodedPath.match(/^https?:\/\/.+/i)) { logDebug(`Warning: Path was not encoded but looks like URL: ${encodedPath}`); return encodedPath; } return null; } } catch (e) { logDebug(`Error decoding target URL: ${encodedPath} - ${e.message}`); return null; } } function getBaseUrl(urlStr) { if (!urlStr) return ''; try { const parsedUrl = new URL(urlStr); const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; } pathSegments.pop(); return `${parsedUrl.origin}/${pathSegments.join('/')}/`; } catch (e) { logDebug(`Getting BaseUrl failed for "${urlStr}": ${e.message}`); const lastSlashIndex = urlStr.lastIndexOf('/'); if (lastSlashIndex > urlStr.indexOf('://') + 2) { return urlStr.substring(0, lastSlashIndex + 1); } return urlStr + '/'; } } function resolveUrl(baseUrl, relativeUrl) { if (!relativeUrl) return ''; if (relativeUrl.match(/^https?:\/\/.+/i)) { return relativeUrl; } if (!baseUrl) return relativeUrl; try { return new URL(relativeUrl, baseUrl).toString(); } catch (e) { logDebug(`URL resolution failed: base="${baseUrl}", relative="${relativeUrl}". Error: ${e.message}`); if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } } else { return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; } } } // ** MODIFIED for Netlify redirect ** function rewriteUrlToProxy(targetUrl) { if (!targetUrl || typeof targetUrl !== 'string') return ''; // Use the path defined in netlify.toml 'from' field return `/proxy/${encodeURIComponent(targetUrl)}`; } function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; } async function fetchContentWithType(targetUrl, requestHeaders) { const headers = { 'User-Agent': getRandomUserAgent(), 'Accept': requestHeaders['accept'] || '*/*', 'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8', 'Referer': requestHeaders['referer'] || new URL(targetUrl).origin, }; Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {}); logDebug(`Fetching target: ${targetUrl} with headers: ${JSON.stringify(headers)}`); try { const response = await fetch(targetUrl, { headers, redirect: 'follow' }); if (!response.ok) { const errorBody = await response.text().catch(() => ''); logDebug(`Fetch failed: ${response.status} ${response.statusText} - ${targetUrl}`); const err = new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`); err.status = response.status; throw err; } const content = await response.text(); const contentType = response.headers.get('content-type') || ''; logDebug(`Fetch success: ${targetUrl}, Content-Type: ${contentType}, Length: ${content.length}`); return { content, contentType, responseHeaders: response.headers }; } catch (error) { logDebug(`Fetch exception for ${targetUrl}: ${error.message}`); throw new Error(`Failed to fetch target URL ${targetUrl}: ${error.message}`); } } function isM3u8Content(content, contentType) { if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; } return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U'); } function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing KEY URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); } function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing MAP URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); } function processMediaPlaylist(url, content) { const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`Could not determine base URL for media playlist: ${url}. Cannot process relative paths.`); } const lines = content.split('\n'); const output = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue; if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; } if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; } if (line.startsWith('#EXTINF')) { output.push(line); continue; } if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`Rewriting media segment: Original='${line}', Resolved='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; } output.push(line); } return output.join('\n'); } async function processM3u8Content(targetUrl, content, recursionDepth = 0) { if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`Detected master playlist: ${targetUrl} (Depth: ${recursionDepth})`); return await processMasterPlaylist(targetUrl, content, recursionDepth); } logDebug(`Detected media playlist: ${targetUrl} (Depth: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content); } async function processMasterPlaylist(url, content, recursionDepth) { if (recursionDepth > MAX_RECURSION) { throw new Error(`Max recursion depth (${MAX_RECURSION}) exceeded for master playlist: ${url}`); } const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = ''; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/); const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0; let variantUriLine = ''; for (let j = i + 1; j < lines.length; j++) { const line = lines[j].trim(); if (line && !line.startsWith('#')) { variantUriLine = line; i = j; break; } } if (variantUriLine && currentBandwidth >= highestBandwidth) { highestBandwidth = currentBandwidth; bestVariantUrl = resolveUrl(baseUrl, variantUriLine); } } } if (!bestVariantUrl) { logDebug(`No BANDWIDTH found, trying first URI in: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) { bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`Fallback: Found first sub-playlist URI: ${bestVariantUrl}`); break; } } } if (!bestVariantUrl) { logDebug(`No valid sub-playlist URI found in master: ${url}. Processing as media playlist.`); return processMediaPlaylist(url, content); } logDebug(`Selected sub-playlist (Bandwidth: ${highestBandwidth}): ${bestVariantUrl}`); const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {}); if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`Fetched sub-playlist ${bestVariantUrl} is not M3U8 (Type: ${variantContentType}). Treating as media playlist.`); return processMediaPlaylist(bestVariantUrl, variantContent); } return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1); } // --- Netlify Handler --- export const handler = async (event, context) => { console.log('--- Netlify Proxy Request ---'); console.log('Time:', new Date().toISOString()); console.log('Method:', event.httpMethod); console.log('Path:', event.path); // Note: event.queryStringParameters contains query params if any // Note: event.headers contains incoming headers // --- CORS Headers (for all responses) --- const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': '*', // Allow all headers client might send }; // --- Handle OPTIONS Preflight Request --- if (event.httpMethod === 'OPTIONS') { logDebug("Handling OPTIONS request"); return { statusCode: 204, headers: { ...corsHeaders, 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours }, body: '', }; } // --- Extract Target URL --- // Based on netlify.toml rewrite: from = "/proxy/*" to = "/.netlify/functions/proxy/:splat" // The :splat part should be available in event.path after the base path let encodedUrlPath = ''; const proxyPrefix = '/proxy/'; // Match the 'from' path in netlify.toml if (event.path && event.path.startsWith(proxyPrefix)) { encodedUrlPath = event.path.substring(proxyPrefix.length); logDebug(`Extracted encoded path from event.path: ${encodedUrlPath}`); } else { logDebug(`Could not extract encoded path from event.path: ${event.path}`); // Potentially handle direct calls too? Less likely needed. // const functionPath = '/.netlify/functions/proxy/'; // if (event.path && event.path.startsWith(functionPath)) { // encodedUrlPath = event.path.substring(functionPath.length); // } } const targetUrl = getTargetUrlFromPath(encodedUrlPath); logDebug(`Resolved target URL: ${targetUrl || 'null'}`); if (!targetUrl) { logDebug('Error: Invalid proxy request path.'); return { statusCode: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, error: "Invalid proxy request path. Could not extract target URL." }), }; } logDebug(`Processing proxy request for target: ${targetUrl}`); try { // Fetch Original Content (Pass Netlify event headers) const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, event.headers); // --- Process if M3U8 --- if (isM3u8Content(content, contentType)) { logDebug(`Processing M3U8 content: ${targetUrl}`); const processedM3u8 = await processM3u8Content(targetUrl, content); logDebug(`Successfully processed M3U8 for ${targetUrl}`); return { statusCode: 200, headers: { ...corsHeaders, // Include CORS headers 'Content-Type': 'application/vnd.apple.mpegurl;charset=utf-8', 'Cache-Control': `public, max-age=${CACHE_TTL}`, // Note: Do NOT include content-encoding or content-length from original response // as node-fetch likely decompressed it and length changed. }, body: processedM3u8, // Netlify expects body as string }; } else { // --- Return Original Content (Non-M3U8) --- logDebug(`Returning non-M3U8 content directly: ${targetUrl}, Type: ${contentType}`); // Prepare headers for Netlify response object const netlifyHeaders = { ...corsHeaders }; responseHeaders.forEach((value, key) => { const lowerKey = key.toLowerCase(); // Exclude problematic headers and CORS headers (already added) if (!lowerKey.startsWith('access-control-') && lowerKey !== 'content-encoding' && lowerKey !== 'content-length') { netlifyHeaders[key] = value; // Add other original headers } }); netlifyHeaders['Cache-Control'] = `public, max-age=${CACHE_TTL}`; // Set our cache policy return { statusCode: 200, headers: netlifyHeaders, body: content, // Body as string // isBase64Encoded: false, // Set true only if returning binary data as base64 }; } } catch (error) { logDebug(`ERROR in proxy processing for ${targetUrl}: ${error.message}`); console.error(`[Proxy Error Stack Netlify] ${error.stack}`); // Log full stack const statusCode = error.status || 500; // Get status from error if available return { statusCode: statusCode, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, error: `Proxy processing error: ${error.message}`, targetUrl: targetUrl }), }; } };