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" }
+ ]
}
]
}