From 8028c4cc9fe0c170cb440ecaa08db452e037381d Mon Sep 17 00:00:00 2001 From: Toast <46423269+Toasty360@users.noreply.github.com> Date: Fri, 1 Nov 2024 11:09:15 -0500 Subject: [PATCH 1/3] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 37b46f3..3cf84a8 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ You can deploy your own instance of this application on Cloudflare Workers by cl [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/Toasty360/Roxy) -> Replace `YOUR_USERNAME` and `YOUR_REPOSITORY` in the URL above with your GitHub username and the repository name where your `index.js` file is located. ## Contributing From ebd7e572a6a007ae33e1250703cdaf37e9f9af26 Mon Sep 17 00:00:00 2001 From: Toasty360 Date: Mon, 11 Nov 2024 08:04:20 -0600 Subject: [PATCH 2/3] enhanced --- src/proxy.js | 199 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 133 insertions(+), 66 deletions(-) diff --git a/src/proxy.js b/src/proxy.js index a6c813d..7a4d257 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1,101 +1,168 @@ const m3u8ContentTypes = ['application/vnd.apple.mpegurl', 'application/x-mpegurl', 'audio/x-mpegurl', 'audio/mpegurl', 'video/x-mpegurl']; +const videoContentTypes = ['video/mp4', 'video/webm', 'video/ogg', 'application/mp4', 'video/x-m4v', ...m3u8ContentTypes]; + +const CACHE_CONTROL_SETTINGS = { + MASTER: 'public, max-age=30, s-maxage=30', + MEDIA: 'public, max-age=300, s-maxage=300', + SEGMENT: 'public, max-age=86400, s-maxage=86400', + KEY: 'public, max-age=3600, s-maxage=3600', + ERROR: 'no-store', +}; -// Function to decode base64 headers and transform into a Headers object function decodeHeaders(base64Headers) { + const headers = new Headers(); + if (!base64Headers) return headers; try { - const decodedString = atob(decodeURIComponent(base64Headers)); + const decodedString = atob(base64Headers); const headersObj = JSON.parse(decodedString); - const headers = new Headers(); - for (const key in headersObj) { - if (headersObj.hasOwnProperty(key)) { - headers.append(key, headersObj[key]); - } - } + + Object.entries(headersObj).forEach(([key, value]) => { + headers.append(key, value); + }); return headers; } catch (error) { return null; } } -async function proxy(request) { - const urlParams = new URL(request.url).searchParams; - const encodedUrl = urlParams.get('url'); - const headersBase64 = urlParams.get('headers'); - - if (!encodedUrl || !headersBase64) { - return new Response('Both "url" and "headers" query parameters are required', { status: 400 }); +function getCacheSettings(url, content) { + if (url.includes('.ts') || url.includes('.m4s')) { + return CACHE_CONTROL_SETTINGS.SEGMENT; } - - // Decode the URL from base64 - const m3u8Url = atob(decodeURIComponent(encodedUrl)); - - // Decode and transform the headers - const decodedHeaders = decodeHeaders(headersBase64); - if (!decodedHeaders) { - return new Response('Invalid headers format. Must be valid base64-encoded JSON.', { status: 400 }); + if (url.includes('.m3u8')) { + return content.includes('#EXT-X-STREAM-INF') ? CACHE_CONTROL_SETTINGS.MASTER : CACHE_CONTROL_SETTINGS.MEDIA; } + if (url.includes('.key')) { + return CACHE_CONTROL_SETTINGS.KEY; + } + return CACHE_CONTROL_SETTINGS.ERROR; +} - const baseUrl = new URL(m3u8Url); - const basePath = `${baseUrl.protocol}//${baseUrl.host}${baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1)}`; +async function proxy(request) { + const cache = caches.default; + const url = new URL(request.url); + + if (request.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + }, + }); + } - console.log(decodeHeaders); + const cacheKey = new Request(url.toString(), request); try { - // Fetch the M3U8 file - const response = await fetch(m3u8Url, { - method: 'GET', - headers: decodedHeaders, - }); + let response = await cache.match(cacheKey); + if (response) return response; - const contentType = response.headers.get('content-type') || 'application/octet-stream'; - const isM3U8 = m3u8ContentTypes.some((type) => contentType.includes(type)); - - if (isM3U8) { - let responseData = ''; - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - responseData += decoder.decode(value, { stream: true }); - } - - const modifiedBody = responseData.replace(/^(?!#)([^\s]+)$/gm, (match) => { - let fullUrl; - if (match.startsWith('http://') || match.startsWith('https://')) { - fullUrl = match; - } else if (match.startsWith('/')) { - fullUrl = `${baseUrl.protocol}//${baseUrl.host}${match}`; - } else { - fullUrl = `${basePath}${match}`; - } - - const proxiedLink = `${new URL(request.url).origin}/proxy?url=${encodeURIComponent(btoa(fullUrl))}&headers=${encodeURIComponent( - headersBase64 - )}`; - - return proxiedLink; + const urlParams = url.searchParams; + const encodedUrl = urlParams.get('url'); + const headersBase64 = urlParams.get('headers'); + + if (!encodedUrl) { + return new Response('Both "url" and "headers" query parameters are required', { + status: 400, + headers: { + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', + }, }); + } + + const mediaUrl = atob(decodeURIComponent(encodedUrl)); + const decodedHeaders = decodeHeaders(headersBase64); - return new Response(modifiedBody, { + if (!decodedHeaders) { + return new Response('Invalid headers format. Must be valid base64-encoded JSON.', { + status: 400, headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', }, }); - } else { - return new Response(response.body, { + } + + const baseUrl = new URL(mediaUrl); + const basePath = `${baseUrl.protocol}//${baseUrl.host}${baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1)}`; + + response = await fetch(mediaUrl, { + headers: { + ...Object.fromEntries(decodedHeaders.entries()), + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + }, + cf: { + cacheEverything: true, + cacheTtl: mediaUrl.includes('.m3u8') ? 300 : 86400, + minify: true, + mirage: true, + polish: 'lossy', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const isM3U8 = videoContentTypes.some((type) => contentType.includes(type)); + const responseContent = isM3U8 ? await response.text() : null; + const cacheControl = isM3U8 ? getCacheSettings(mediaUrl, responseContent) : CACHE_CONTROL_SETTINGS.SEGMENT; + + if (!isM3U8) { + const newResponse = new Response(response.body, { headers: { 'Content-Type': contentType, + 'Cache-Control': cacheControl, 'Access-Control-Allow-Origin': '*', + 'CF-Cache-Status': 'DYNAMIC', + 'Accept-Ranges': 'bytes', + 'Transfer-Encoding': 'chunked', }, }); + + await cache.put(cacheKey, newResponse.clone()); + return newResponse; + } + + const modifiedBody = responseContent.replace(/^(?!#)([^\s]+)$/gm, (match) => { + const fullUrl = match.startsWith('http') + ? match + : match.startsWith('/') + ? `${baseUrl.protocol}//${baseUrl.host}${match}` + : `${basePath}${match}`; + + return `${new URL(request.url).origin}/proxy?url=${encodeURIComponent(btoa(fullUrl))}&headers=${encodeURIComponent(headersBase64)}`; + }); + + const newResponse = new Response(modifiedBody, { + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': cacheControl, + 'Access-Control-Allow-Origin': '*', + 'CF-Cache-Status': 'DYNAMIC', + 'Accept-Ranges': 'bytes', + 'Transfer-Encoding': 'chunked', + }, + }); + + if (cacheControl !== CACHE_CONTROL_SETTINGS.ERROR && request.method !== 'HEAD') { + await cache.put(cacheKey, newResponse.clone()); } + return newResponse; } catch (error) { - console.error('Error fetching the M3U8 file:', error.message); - return new Response('Error fetching the M3U8 file: ' + error.message, { + console.error('Error in proxy:', error); + return new Response(`Proxy error: ${error.message}`, { status: 500, + headers: { + 'Cache-Control': CACHE_CONTROL_SETTINGS.ERROR, + 'Access-Control-Allow-Origin': '*', + }, }); } } From ead16abfd688e3927116938a15c27d6d5dce0e19 Mon Sep 17 00:00:00 2001 From: Toasty360 Date: Mon, 11 Nov 2024 09:14:36 -0600 Subject: [PATCH 3/3] added workflows --- .github/workflows/publish.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..15fa011 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Deploy to Cloudflare Worker + +on: + push: + branches: + - main + pull_request: + branches: + - main + repository_dispatch: +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + deploy: + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: build + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Wrangler CLI + uses: cloudflare/wrangler-action@1.3.0 + with: + apiToken: ${{ secrets.CF_API_TOKEN }}