diff --git a/packages/website/app/proxy/blocklist.ts b/packages/website/app/proxy/blocklist.ts new file mode 100644 index 000000000..692f19db2 --- /dev/null +++ b/packages/website/app/proxy/blocklist.ts @@ -0,0 +1,58 @@ +const BLOCKED_DOMAIN_PATTERNS = [ + // Adult content + "pornhub", + "xvideos", + "xnxx", + "xhamster", + "redtube", + "youporn", + "tube8", + "spankbang", + "eporner", + "xxxvideos", + "porn", + "xxx", + "sex", + "adult", + "nsfw", + "onlyfans", + "fansly", + "chaturbate", + "livejasmin", + "stripchat", + "cam4", + "bongacams", + "myfreecams", + "camsoda", + "flirt4free", + + // Gambling + "casino", + "poker", + "betting", + "slots", + "gambling", + + // Malware/Phishing common patterns + "malware", + "phishing", + + // Piracy + "thepiratebay", + "1337x", + "rarbg", + "torrent", + + // Self-referential (prevent infinite loops) + "react-grab.com", + "localhost", + "127.0.0.1", +]; + +export const isBlockedDomain = (hostname: string): boolean => { + const lowerHostname = hostname.toLowerCase(); + + return BLOCKED_DOMAIN_PATTERNS.some((pattern) => + lowerHostname.includes(pattern.toLowerCase()), + ); +}; diff --git a/packages/website/app/proxy/route.ts b/packages/website/app/proxy/route.ts new file mode 100644 index 000000000..bdc6f3a53 --- /dev/null +++ b/packages/website/app/proxy/route.ts @@ -0,0 +1,166 @@ +import { isBlockedDomain } from "./blocklist"; + +export const runtime = "edge"; + +const REACT_GRAB_SCRIPT = ``; + +const createErrorResponse = (message: string, status: number): Response => { + return new Response( + ` + + + + + Proxy Error + + + +
+

Proxy Error

+

${message}

+
+ +`, + { + status, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }, + ); +}; + +const rewriteHtml = (html: string, baseUrl: string): string => { + const baseUrlObj = new URL(baseUrl); + const baseOrigin = baseUrlObj.origin; + + let result = html; + + // Remove any existing tag to prevent conflicts + result = result.replace( + /]*>/gi, + "", + ); + + // Create injection: tag + React Grab script + // The tag makes all relative URLs resolve to the original domain + // This is much simpler than rewriting all URLs and works with dynamic imports + const injection = `\n${REACT_GRAB_SCRIPT}`; + + // Inject into + const headMatch = result.match(/]*>/i); + if (headMatch) { + const headEndIndex = headMatch.index! + headMatch[0].length; + result = + result.slice(0, headEndIndex) + + "\n" + + injection + + "\n" + + result.slice(headEndIndex); + } else { + // No tag, inject after or at the start + const htmlMatch = result.match(/]*>/i); + if (htmlMatch) { + const htmlEndIndex = htmlMatch.index! + htmlMatch[0].length; + result = + result.slice(0, htmlEndIndex) + + "\n\n" + + injection + + "\n\n" + + result.slice(htmlEndIndex); + } else { + result = injection + "\n" + result; + } + } + + return result; +}; + +export const GET = async (request: Request): Promise => { + const requestUrl = new URL(request.url); + const targetUrl = requestUrl.searchParams.get("url"); + + if (!targetUrl) { + return createErrorResponse( + "Missing 'url' parameter. Usage: /proxy?url=https://example.com", + 400, + ); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(targetUrl); + } catch { + return createErrorResponse("Invalid URL provided.", 400); + } + + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return createErrorResponse("Only HTTP and HTTPS URLs are supported.", 400); + } + + if (isBlockedDomain(parsedUrl.hostname)) { + return createErrorResponse("This domain is not allowed.", 403); + } + + try { + const response = await fetch(targetUrl, { + headers: { + "User-Agent": + request.headers.get("User-Agent") || + "Mozilla/5.0 (compatible; ReactGrabProxy/1.0)", + Accept: request.headers.get("Accept") || "*/*", + "Accept-Language": + request.headers.get("Accept-Language") || "en-US,en;q=0.9", + }, + redirect: "follow", + }); + + if (!response.ok) { + return createErrorResponse( + `Failed to fetch: ${response.status} ${response.statusText}`, + response.status, + ); + } + + const contentType = response.headers.get("Content-Type") || ""; + const isHtml = contentType.includes("text/html"); + + // For HTML, inject tag and React Grab script + if (isHtml) { + const html = await response.text(); + const transformedHtml = rewriteHtml(html, targetUrl); + + return new Response(transformedHtml, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "X-Proxied-From": targetUrl, + }, + }); + } + + // For non-HTML content, return error (proxy only handles HTML pages) + return createErrorResponse( + "This proxy only handles HTML pages. For assets, use the original URL.", + 400, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return createErrorResponse(`Proxy error: ${message}`, 500); + } +}; diff --git a/vercel.json b/vercel.json index 04de9a1dc..8ca47bc12 100644 --- a/vercel.json +++ b/vercel.json @@ -23,6 +23,13 @@ "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" } ] + }, + { + "source": "/proxy", + "headers": [ + { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, + { "key": "X-Content-Type-Options", "value": "nosniff" } + ] } ] }