const express = require('express') const fs = require('fs') const path = require('path') const { Readable } = require('stream') const babel = require('@babel/core') const regeneratorRuntimeCode = fs.readFileSync(require.resolve('regenerator-runtime/runtime'), 'utf8') const app = express() app.disable('x-powered-by') app.set('trust proxy', true) const MAX_BYTES = 2 * 1024 * 1024 const MAX_CSS_DEPTH = 6 function getSelfBase(req) { const protoRaw = (req.headers['x-forwarded-proto'] || req.protocol || 'https') + '' const proto = protoRaw.split(',')[0].trim() || 'https' const host = (req.headers['x-forwarded-host'] || req.get('host') || '').split(',')[0].trim() return proto + '://' + host } function normalizeUrl(u) { if (!u) return null let s = String(u).trim() if (!s) return null // Если URL уже закодирован, раскодируем его один раз перед проверкой if (s.indexOf('%3A') !== -1 || s.indexOf('%3a') !== -1) { try { s = decodeURIComponent(s) } catch(e) {} } if (!/^https?:\/\//i.test(s)) { s = 'https://' + s } try { const url = new URL(s) if (url.protocol !== 'http:' && url.protocol !== 'https:') return null url.hash = '' return url.toString() } catch (e) { return null } } async function fetchText(url, timeoutMs, clientUA) { const controller = new AbortController() const t = setTimeout(() => controller.abort(), timeoutMs) try { const res = await fetch(url, { redirect: 'follow', signal: controller.signal, headers: { 'user-agent': clientUA || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } }) const status = res.status if (!res.ok) { const txt = await res.text().catch(() => '') const e = new Error('Fetch failed: ' + status) e.status = status e.body = txt throw e } const buf = await res.arrayBuffer() if (buf.byteLength > MAX_BYTES) { const e = new Error('Too large') e.status = 413 throw e } const text = Buffer.from(buf).toString('utf8') const contentType = res.headers.get('content-type') || '' return { text, contentType, status } } finally { clearTimeout(t) } } function jsWrapper(serverBase, baseUrl, transpiledCode, needRegenerator, useVpn, useEs5, useStream) { const safeServer = JSON.stringify(String(serverBase || '')) const safeBaseUrl = JSON.stringify(String(baseUrl || '')) const fetchPolyfill = '(function(){' + "if(typeof window.fetch==='function')return;" + "function H(h){this.map={};if(!h)return;for(var k in h)if(h.hasOwnProperty(k))this.map[k.toLowerCase()]=String(h[k]);}" + "H.prototype.get=function(k){return this.map[String(k).toLowerCase()]||null;};" + "H.prototype.has=function(k){return this.map.hasOwnProperty(String(k).toLowerCase());};" + "function R(b,o){this._body=b||'';this.status=o&&o.status||200;this.statusText=o&&o.statusText||'OK';this.headers=new H(o&&o.headers||{});this.ok=this.status>=200&&this.status<300;}" + "R.prototype.text=function(){return Promise.resolve(String(this._body));};" + "R.prototype.json=function(){return Promise.resolve(String(this._body)).then(JSON.parse);};" + "window.fetch=function(u,opt){opt=opt||{};return new Promise(function(resolve,reject){try{var x=new XMLHttpRequest();x.open((opt.method||'GET'),u,true);var h=opt.headers||{};for(var k in h)if(h.hasOwnProperty(k))x.setRequestHeader(k,h[k]);x.onreadystatechange=function(){if(x.readyState===4){resolve(new R(x.responseText,{status:x.status||0,statusText:x.statusText||'',headers:{}}));}};x.onerror=function(){reject(new Error('Network error'));};x.send(opt.body||null);}catch(e){reject(e);}});};" + '})();' const cssVarsPonyfill = '(function(){' + 'function t(v){return (v===null||v===undefined)?\'\':String(v);}' + 'function gOrigin(u){try{var a=document.createElement("a");a.href=u;return (a.protocol? a.protocol:"") + "//" + (a.host||"");}catch(e){return "";}}' + 'function collect(){var map={};var styles=document.getElementsByTagName("style");for(var i=0;i MAX_CSS_DEPTH) return '' const { text } = await fetchText(url, 25_000, clientUA) const base = url.replace(/[?#].*$/, '').replace(/[^/]*$/, '') let css = text css = css.replace(/@import\s+(?:url\()?\s*["']?([^"')\s]+)["']?\s*\)?\s*;/gi, (m, imp) => { try { const abs = new URL(String(imp), base).toString() return '/*__es5_import__:' + abs + '*/' } catch (e) { return '' } }) const imports = [] css.replace(/\/\*__es5_import__:(.*?)\*\//g, (m, u) => { imports.push(u) return m }) for (let i = 0; i < imports.length; i++) { let inlined = '' try { inlined = await cssInline(imports[i], depth + 1, clientUA) } catch (e) { inlined = '' } css = css.replace('/*__es5_import__:' + imports[i] + '*/', inlined) } css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, q, u) => { const raw = String(u || '').trim() if (!raw) return m if (/^(data:|blob:|https?:\/\/|\/\/)/i.test(raw)) return 'url(' + raw + ')' try { const abs = new URL(raw, base).toString() return 'url(' + abs + ')' } catch (e) { return m } }) return css } app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', '*'); next(); }) app.get('/health', (req, res) => { res.status(200).json({ ok: true }) }) app.get('/client/es5_proxy_store.js', (req, res) => { res.setHeader('content-type', 'application/javascript; charset=utf-8') res.setHeader('cache-control', 'public, max-age=600') res.sendFile(path.join(__dirname, '..', 'es5_proxy_store.js')) }) app.get('/s', async (req, res) => { const source = normalizeUrl(req.query.url) if (!source) return res.status(400).type('text/plain').send('Bad url') try { const css = await cssInline(source, 0, req.headers['user-agent']) res.setHeader('content-type', 'text/css; charset=utf-8') res.setHeader('cache-control', 'public, max-age=600') res.status(200).send(css) } catch (e) { const code = e && e.status ? e.status : 500 res.status(code).type('text/plain').send('CSS fetch error') } }) app.get('/p', async (req, res) => { let rawUrl = req.query.url; // Попытка раскодировать URL, если он пришел дважды закодированным if(rawUrl && (rawUrl.indexOf('%3A') !== -1 || rawUrl.indexOf('%3a') !== -1)){ try { rawUrl = decodeURIComponent(rawUrl); } catch(e){} } const source = normalizeUrl(rawUrl) if (!source) return res.status(400).type('text/plain').send('Bad url') const useEs5 = req.query.es5 !== '0'; const useVpn = req.query.vpn !== '0'; const useStream = req.query.stream === '1'; try { const { text } = await fetchText(source, 25_000, req.headers['user-agent']) let code = text; let needRegenerator = false; if (useEs5) { const result = await babel.transformAsync(text, { babelrc: false, configFile: false, sourceType: 'script', compact: true, comments: false, presets: [ [ require('@babel/preset-env'), { targets: { chrome: '38' }, bugfixes: true, loose: true, modules: false } ] ] }) if (result && result.code) { code = result.code; needRegenerator = code.indexOf('regeneratorRuntime') !== -1; } } const serverBase = getSelfBase(req) const out = jsWrapper(serverBase, source, code, needRegenerator, useVpn, useEs5, useStream) res.setHeader('content-type', 'application/javascript; charset=utf-8') res.setHeader('cache-control', 'public, max-age=600') res.status(200).send(out) } catch (e) { const code = e && e.status ? e.status : 500 res.status(code).type('text/plain').send('JS transform error') } }) // Универсальный "сырой" прокси: любые данные как есть — JSON от API источника, // картинки, m3u8/mp4-потоки и т.д. Без Babel и без лимита в MAX_BYTES, со стримингом // и поддержкой Range (нужно для перемотки видео). const RAW_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] // Заголовки, которые НЕ пробрасываем на апстрим — специфичные для соединения // клиент<->наш сервер, пересылать их дальше некорректно/бессмысленно. const REQ_HEADERS_SKIP = new Set([ 'host', 'connection', 'content-length', 'accept-encoding', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'cf-connecting-ip', 'cf-ray', 'cf-visitor', 'x-real-ip', 'origin', 'referer' ]) const RES_HEADERS_PASS = [ 'content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'expires', 'last-modified', 'etag', 'set-cookie' ] app.all('/r', async (req, res) => { let rawUrl = req.query.url if (rawUrl && (rawUrl.indexOf('%3A') !== -1 || rawUrl.indexOf('%3a') !== -1)) { try { rawUrl = decodeURIComponent(rawUrl) } catch (e) {} } const source = normalizeUrl(rawUrl) if (!source) return res.status(400).type('text/plain').send('Bad url') const method = (req.method || 'GET').toUpperCase() if (RAW_METHODS.indexOf(method) === -1) { return res.status(405).type('text/plain').send('Method not allowed') } if (method === 'OPTIONS') return res.sendStatus(204) const controller = new AbortController() req.on('close', () => controller.abort()) const headers = {} for (const h in req.headers) { if (REQ_HEADERS_SKIP.has(h)) continue if (h.indexOf('sec-') === 0) continue // sec-fetch-*, sec-ch-ua* — метаданные браузера, апстриму не нужны и могут смутить его антибот-защиту headers[h] = req.headers[h] } const targetUrl = new URL(source); headers['origin'] = targetUrl.origin; headers['referer'] = targetUrl.origin + '/'; if (!headers['user-agent']) { 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'; } let body if (method !== 'GET' && method !== 'HEAD') { const chunks = [] for await (const chunk of req) chunks.push(chunk) if (chunks.length) body = Buffer.concat(chunks) } try { const upstream = await fetch(source, { method, headers, body, redirect: 'follow', signal: controller.signal }) const isJson = (upstream.headers.get('content-type') || '').toLowerCase().includes('application/json'); const isM3u8 = (upstream.headers.get('content-type') || '').toLowerCase().includes('mpegurl') || source.toLowerCase().includes('.m3u8'); if (isJson) { const buffer = await upstream.arrayBuffer(); let text = Buffer.from(buffer).toString('utf8'); // Replace wss:// urls to go through our proxy const serverBase = getSelfBase(req); const wsBase = serverBase.replace(/^http/, 'ws'); text = text.replace(/wss:\/\/[^"'\s]+/g, (match) => { return wsBase + '/ws?url=' + encodeURIComponent(match); }); const outBuf = Buffer.from(text, 'utf8'); res.status(upstream.status); for (const h of RES_HEADERS_PASS) { if (h === 'content-length') { res.setHeader(h, outBuf.length); continue; } let v; if (h === 'set-cookie') { const cookies = upstream.headers.getSetCookie ? upstream.headers.getSetCookie() : []; if (cookies.length) { // Strip domain from cookies so they apply to our proxy's domain v = cookies.map(c => c.replace(/domain=[^;]+;?\s*/gi, '')); } } else { v = upstream.headers.get(h); } if (v) res.setHeader(h, v); } res.setHeader('access-control-allow-origin', '*'); return res.end(outBuf); } else if (isM3u8) { const buffer = await upstream.arrayBuffer(); let text = Buffer.from(buffer).toString('utf8'); const serverBase = getSelfBase(req); const baseUrl = new URL(source); const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { let line = lines[i].trim(); if (line && !line.startsWith('#')) { try { const absoluteUrl = new URL(line, baseUrl).toString(); lines[i] = serverBase + '/r?url=' + encodeURIComponent(absoluteUrl); } catch(e) {} } else if (line.startsWith('#EXT-X-STREAM-INF:') || line.startsWith('#EXT-X-MEDIA:') || line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) { lines[i] = line.replace(/URI=["']([^"']+)["']/g, (match, uri) => { try { const absoluteUrl = new URL(uri, baseUrl).toString(); return `URI="${serverBase}/r?url=${encodeURIComponent(absoluteUrl)}"`; } catch(e) { return match; } }); } } text = lines.join('\n'); const outBuf = Buffer.from(text, 'utf8'); res.status(upstream.status); for (const h of RES_HEADERS_PASS) { if (h === 'content-length') { res.setHeader(h, outBuf.length); continue; } let v = upstream.headers.get(h); if (v) res.setHeader(h, v); } res.setHeader('access-control-allow-origin', '*'); return res.end(outBuf); } res.status(upstream.status) for (const h of RES_HEADERS_PASS) { if (h === 'content-length') { const ct = upstream.headers.get('content-type') || ''; if (!ct.includes('video/') && !ct.includes('audio/') && !upstream.headers.has('content-range')) { continue; // fetch decompresses gzip/br, so original content-length is invalid for text/json } } let v; if (h === 'set-cookie') { const cookies = upstream.headers.getSetCookie ? upstream.headers.getSetCookie() : []; if (cookies.length) { v = cookies.map(c => c.replace(/domain=[^;]+;?\s*/gi, '')); } } else { v = upstream.headers.get(h) } if (v) res.setHeader(h, v) } res.setHeader('access-control-allow-origin', '*') if (!upstream.body) return res.end() Readable.fromWeb(upstream.body).pipe(res) } catch (e) { if (!res.headersSent) { const code = e && e.status ? e.status : 502 res.status(code).type('text/plain').send('Proxy error') } else { try { res.end() } catch (e2) {} } } }) app.options('*', (req, res) => res.sendStatus(204)) const port = Number(process.env.PORT || 7860) const server = app.listen(port, () => { console.log('lampa-es5-proxy on :' + port) }) server.on('upgrade', (req, clientSocket, head) => { try { const urlObj = new URL(req.url, 'http://localhost'); if (urlObj.pathname === '/ws') { let targetUrlStr = urlObj.searchParams.get('url'); if (!targetUrlStr) { clientSocket.destroy(); return; } const targetUrl = new URL(targetUrlStr); // Pass along any extra query parameters Lampa might have appended (like &nws_id=...) urlObj.searchParams.forEach((value, key) => { if (key !== 'url') { targetUrl.searchParams.append(key, value); } }); // Filter headers carefully const proxyHeaders = {}; for (const h in req.headers) { if (['host', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'cf-connecting-ip', 'cf-ray', 'cf-visitor'].includes(h.toLowerCase())) continue; proxyHeaders[h] = req.headers[h]; } proxyHeaders['host'] = targetUrl.hostname; proxyHeaders['origin'] = targetUrl.protocol.replace('ws', 'http') + '//' + targetUrl.hostname; if (!proxyHeaders['user-agent']) { proxyHeaders['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'; } const options = { port: targetUrl.port || (targetUrl.protocol === 'wss:' ? 443 : 80), host: targetUrl.hostname, servername: targetUrl.hostname, // CRITICAL FOR SNI (Cloudflare drops without this) headers: proxyHeaders, path: targetUrl.pathname + targetUrl.search, rejectUnauthorized: false }; const proto = targetUrl.protocol === 'wss:' ? require('https') : require('http'); const proxyReq = proto.request(options); proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => { let headers = 'HTTP/1.1 101 Switching Protocols\r\n'; for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) { headers += proxyRes.rawHeaders[i] + ': ' + proxyRes.rawHeaders[i+1] + '\r\n'; } headers += '\r\n'; clientSocket.write(headers); if (proxyHead && proxyHead.length) clientSocket.write(proxyHead); proxySocket.pipe(clientSocket); clientSocket.pipe(proxySocket); }); // Handle server rejecting the WS connection (e.g. 403 or 400) proxyReq.on('response', (proxyRes) => { console.error('WS Proxy rejected with status:', proxyRes.statusCode); clientSocket.write(`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n\r\n`); clientSocket.destroy(); }); proxyReq.on('error', (err) => { console.error('WS Proxy error:', err.message); clientSocket.destroy(); }); clientSocket.on('error', () => { proxyReq.destroy(); }); // Send the initial upgrade request proxyReq.end(); } else { clientSocket.destroy(); } } catch(e) { console.error('WS Upgrade catch error:', e.message); clientSocket.destroy(); } })