489 lines
17 KiB
PHP
489 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Proxy;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Cookie\CookieJar;
|
|
use GuzzleHttp\Exception\RequestException;
|
|
|
|
class ProxyService
|
|
{
|
|
protected $client;
|
|
protected $lastContentType;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->client = new Client([
|
|
'timeout' => 30,
|
|
'verify' => false,
|
|
'http_errors' => false,
|
|
'cookies' => new CookieJar(), // Enables cookies
|
|
'allow_redirects' => [
|
|
'max' => 10, // Allow up to 10 redirects
|
|
'strict' => false,
|
|
'referer' => true,
|
|
'track_redirects' => true
|
|
],
|
|
'headers' => [
|
|
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
'Accept-Language' => 'en-US,en;q=0.5',
|
|
'Connection' => 'keep-alive',
|
|
'Upgrade-Insecure-Requests' => '1'
|
|
],
|
|
'curl' => [
|
|
CURLOPT_DNS_USE_GLOBAL_CACHE => true,
|
|
CURLOPT_DNS_CACHE_TIMEOUT => 3600,
|
|
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
CURLOPT_TIMEOUT => 30,
|
|
CURLOPT_SSL_VERIFYPEER => false,
|
|
CURLOPT_SSL_VERIFYHOST => false
|
|
]
|
|
]);
|
|
}
|
|
|
|
public function getLastContentType()
|
|
{
|
|
return $this->lastContentType;
|
|
}
|
|
|
|
public function fetchUrl($url)
|
|
{
|
|
try {
|
|
|
|
$url = $this->replaceUrl($url);
|
|
|
|
// Normalize and encode the URL
|
|
$url = $this->normalizeUrl($url);
|
|
|
|
// Log the request details
|
|
\Log::info('Fetching URL', [
|
|
'url' => $url
|
|
]);
|
|
|
|
try {
|
|
$response = $this->client->get($url);
|
|
} catch (RequestException $e) {
|
|
// Log the specific error
|
|
\Log::error('Request failed with specific error', [
|
|
'url' => $url,
|
|
'error' => $e->getMessage(),
|
|
'code' => $e->getCode()
|
|
]);
|
|
|
|
// If it's a DNS resolution error, try with IP resolution
|
|
if (strpos($e->getMessage(), 'Could not resolve host') !== false) {
|
|
try {
|
|
// Try to resolve the hostname first
|
|
$parsedUrl = parse_url($url);
|
|
$host = $parsedUrl['host'];
|
|
$ip = gethostbyname($host);
|
|
|
|
if ($ip === $host) {
|
|
throw new \Exception("Could not resolve hostname: $host");
|
|
}
|
|
|
|
// Replace hostname with IP in URL
|
|
$url = str_replace($host, $ip, $url);
|
|
|
|
// Add Host header to maintain virtual hosting
|
|
$response = $this->client->get($url, [
|
|
'headers' => [
|
|
'Host' => $host,
|
|
'Accept-Encoding' => 'identity' // Request uncompressed data
|
|
]
|
|
]);
|
|
|
|
$finalUrl = $response->getHeaderLine('X-Guzzle-Redirect-History') ?: $url;
|
|
|
|
// If redirected to a YouTube consent page, modify the request
|
|
if (strpos($finalUrl, 'consent.youtube.com') !== false) {
|
|
\Log::info('YouTube consent page detected, bypassing...');
|
|
$url = str_replace('consent.youtube.com', 'www.youtube.com', $finalUrl);
|
|
$response = $this->client->get($url);
|
|
}
|
|
|
|
} catch (\Exception $dnsError) {
|
|
\Log::error('DNS resolution failed', [
|
|
'url' => $url,
|
|
'error' => $dnsError->getMessage()
|
|
]);
|
|
throw $dnsError;
|
|
}
|
|
} else {
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
// Store the content type
|
|
$this->lastContentType = $response->getHeaderLine('Content-Type');
|
|
|
|
// Log the response details
|
|
\Log::info('Response received', [
|
|
'url' => $url,
|
|
'status' => $response->getStatusCode(),
|
|
'content_type' => $this->lastContentType,
|
|
'headers' => $response->getHeaders()
|
|
]);
|
|
|
|
$content = $response->getBody()->getContents();
|
|
|
|
// Process the content to rewrite URLs
|
|
$content = $this->processContent($content, $url);
|
|
|
|
return $content;
|
|
} catch (RequestException $e) {
|
|
// Log the error
|
|
\Log::error('Request failed', [
|
|
'url' => $url,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
protected function normalizeUrl($url)
|
|
{
|
|
// Remove any existing encoding
|
|
$url = urldecode($url);
|
|
|
|
// Parse the URL
|
|
$parsedUrl = parse_url($url);
|
|
|
|
// Reconstruct the URL with proper encoding
|
|
$scheme = $parsedUrl['scheme'] ?? 'http';
|
|
$host = $parsedUrl['host'] ?? '';
|
|
$path = $parsedUrl['path'] ?? '/';
|
|
$query = $parsedUrl['query'] ?? '';
|
|
$fragment = $parsedUrl['fragment'] ?? '';
|
|
|
|
// Encode path components
|
|
$path = implode('/', array_map('rawurlencode', explode('/', $path)));
|
|
|
|
// Build the URL
|
|
$url = $scheme . '://' . $host . $path;
|
|
|
|
if ($query) {
|
|
$url .= '?' . $query;
|
|
}
|
|
|
|
if ($fragment) {
|
|
$url .= '#' . $fragment;
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
protected function processContent($content, $baseUrl)
|
|
{
|
|
// Log the content type we're processing
|
|
\Log::info('Processing content', [
|
|
'url' => $baseUrl,
|
|
'content_type' => $this->lastContentType
|
|
]);
|
|
|
|
// If the content is CSS, process it differently
|
|
if (strpos($this->lastContentType, 'text/css') !== false ||
|
|
strpos($this->lastContentType, 'application/css') !== false ||
|
|
strpos($baseUrl, '.css') !== false) {
|
|
|
|
\Log::info('Processing CSS content', [
|
|
'url' => $baseUrl,
|
|
'content_type' => $this->lastContentType
|
|
]);
|
|
|
|
return $this->processCssContent($content, $baseUrl);
|
|
}
|
|
|
|
// Process stylesheet links first
|
|
$content = preg_replace_callback(
|
|
'/<link[^>]*rel=["\']stylesheet["\'][^>]*href=["\'](?!https?:\/\/)([^"\']+)["\']/i',
|
|
function($matches) use ($baseUrl) {
|
|
$url = $matches[1];
|
|
// If the URL starts with /, use the host from baseUrl
|
|
if (strpos($url, '/') === 0) {
|
|
$parsedUrl = parse_url($baseUrl);
|
|
$url = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . $url;
|
|
} else {
|
|
// Otherwise, append to the base URL
|
|
$url = rtrim($baseUrl, '/') . '/' . ltrim($url, '/');
|
|
}
|
|
|
|
// Log the transformation
|
|
\Log::info('Stylesheet URL transformed', [
|
|
'original' => $matches[0],
|
|
'transformed' => $url
|
|
]);
|
|
|
|
return str_replace($matches[1], $url, $matches[0]);
|
|
},
|
|
$content
|
|
);
|
|
|
|
// Process external links (only anchor tags)
|
|
$content = preg_replace_callback(
|
|
'/(<a[^>]*href)=["\'](https?:\/\/[^"\']+)["\']/i',
|
|
function($matches) use ($baseUrl) {
|
|
$url = $matches[2];
|
|
|
|
// Skip URLs that are already going through our proxy
|
|
if (strpos($url, route('proxy.browse')) === 0) {
|
|
return $matches[0];
|
|
}
|
|
|
|
// Skip URLs from the same domain
|
|
if (strpos($url, parse_url($baseUrl, PHP_URL_HOST)) !== false) {
|
|
return $matches[0];
|
|
}
|
|
|
|
// Proxy external URLs
|
|
$encodedUrl = base64_encode($url);
|
|
$proxiedUrl = route('proxy.browse') . '?url=' . $encodedUrl;
|
|
|
|
// Log the URL transformation
|
|
\Log::info('URL transformed', [
|
|
'original' => $matches[0],
|
|
'transformed' => $proxiedUrl
|
|
]);
|
|
|
|
return $matches[1] . '="' . htmlspecialchars($proxiedUrl) . '"';
|
|
},
|
|
$content
|
|
);
|
|
|
|
// Add JavaScript to handle dynamic content
|
|
$content = $this->addLinkHandlerScript($content);
|
|
|
|
return $content;
|
|
}
|
|
|
|
protected function processCssContent($content, $baseUrl)
|
|
{
|
|
// Log the CSS processing start
|
|
\Log::info('Starting CSS processing', [
|
|
'url' => $baseUrl,
|
|
'content_length' => strlen($content)
|
|
]);
|
|
|
|
// Process @import statements
|
|
$content = preg_replace_callback(
|
|
'/@import\s+["\'](?!http|https|data:)([^"\']+)["\']/i',
|
|
function($matches) use ($baseUrl) {
|
|
$url = $matches[1];
|
|
if (strpos($url, '/') === 0) {
|
|
$parsedUrl = parse_url($baseUrl);
|
|
$url = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . $url;
|
|
} else {
|
|
$url = rtrim($baseUrl, '/') . '/' . ltrim($url, '/');
|
|
}
|
|
$url = $this->normalizeUrl($url);
|
|
\Log::info('CSS @import processed', [
|
|
'original' => $matches[0],
|
|
'transformed' => $url
|
|
]);
|
|
return '@import "' . htmlspecialchars($url) . '"';
|
|
},
|
|
$content
|
|
);
|
|
|
|
// Process url() functions
|
|
$content = preg_replace_callback(
|
|
'/url\(["\']?(?!http|https|data:)([^"\'\)]+)["\']?\)/i',
|
|
function($matches) use ($baseUrl) {
|
|
$url = $matches[1];
|
|
if (strpos($url, '/') === 0) {
|
|
$parsedUrl = parse_url($baseUrl);
|
|
$url = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . $url;
|
|
} else {
|
|
$url = rtrim($baseUrl, '/') . '/' . ltrim($url, '/');
|
|
}
|
|
$url = $this->normalizeUrl($url);
|
|
\Log::info('CSS url() processed', [
|
|
'original' => $matches[0],
|
|
'transformed' => $url
|
|
]);
|
|
return 'url("' . htmlspecialchars($url) . '")';
|
|
},
|
|
$content
|
|
);
|
|
|
|
// Log the processed CSS content
|
|
\Log::info('CSS content processed', [
|
|
'url' => $baseUrl,
|
|
'content_length' => strlen($content),
|
|
'content_type' => $this->lastContentType
|
|
]);
|
|
|
|
return $content;
|
|
}
|
|
|
|
protected function processSrcset($srcset, $baseUrl)
|
|
{
|
|
// Split srcset into individual sources
|
|
$sources = preg_split('/,\s*/', $srcset);
|
|
$processedSources = [];
|
|
|
|
foreach ($sources as $source) {
|
|
// Split into URL and descriptor
|
|
$parts = preg_split('/\s+/', trim($source));
|
|
$url = $parts[0];
|
|
$descriptor = isset($parts[1]) ? ' ' . $parts[1] : '';
|
|
|
|
// Skip data URLs
|
|
if (strpos($url, 'data:') === 0) {
|
|
$processedSources[] = $source;
|
|
continue;
|
|
}
|
|
|
|
// Convert relative URL to absolute
|
|
if (strpos($url, '/') === 0) {
|
|
$parsedUrl = parse_url($baseUrl);
|
|
$url = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . $url;
|
|
} else {
|
|
$url = rtrim($baseUrl, '/') . '/' . ltrim($url, '/');
|
|
}
|
|
|
|
// Encode the URL
|
|
$url = $this->normalizeUrl($url);
|
|
|
|
// Rewrite external URLs to go through our proxy
|
|
if (strpos($url, 'http') === 0 && strpos($url, parse_url($baseUrl, PHP_URL_HOST)) === false) {
|
|
$url = route('proxy.browse') . '?url=' . base64_encode($url);
|
|
}
|
|
|
|
$processedSources[] = htmlspecialchars($url) . $descriptor;
|
|
}
|
|
|
|
return 'srcset="' . implode(', ', $processedSources) . '"';
|
|
}
|
|
|
|
protected function addLinkHandlerScript($content)
|
|
{
|
|
// Add script before closing body tag
|
|
$script = <<<EOT
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Function to encode URL using base64
|
|
function encodeUrl(url) {
|
|
return btoa(encodeURIComponent(url));
|
|
}
|
|
|
|
// Function to convert relative URL to absolute
|
|
function toAbsoluteUrl(url) {
|
|
if (!url) return url;
|
|
if (url.startsWith('http')) return url;
|
|
if (url.startsWith('//')) return window.location.protocol + url;
|
|
if (url.startsWith('/')) return window.location.origin + url;
|
|
return window.location.origin + '/' + url;
|
|
}
|
|
|
|
// Function to check if URL should be proxied
|
|
function shouldProxyUrl(url) {
|
|
if (!url) return false;
|
|
if (url.startsWith('{{ route('proxy.browse') }}')) return false;
|
|
if (url.startsWith('data:')) return false;
|
|
if (url.startsWith('javascript:')) return false;
|
|
if (url.startsWith('mailto:')) return false;
|
|
if (url.startsWith('tel:')) return false;
|
|
if (url.includes('youtube.com') && !url.includes('watch?')) return false; // Allow YouTube navigation
|
|
return url.startsWith('http') && !url.includes(window.location.hostname);
|
|
}
|
|
|
|
// Handle clicks on anchor tags
|
|
document.addEventListener('click', function(e) {
|
|
var target = e.target;
|
|
while (target && target.tagName !== 'A') {
|
|
target = target.parentNode;
|
|
}
|
|
if (target && target.tagName === 'A') {
|
|
var href = target.getAttribute('href');
|
|
if (href) {
|
|
href = toAbsoluteUrl(href);
|
|
if (shouldProxyUrl(href)) {
|
|
e.preventDefault();
|
|
var encodedUrl = encodeUrl(href);
|
|
window.location.href = '{{ route('proxy.browse') }}?url=' + encodedUrl;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle form submissions
|
|
document.addEventListener('submit', function(e) {
|
|
var form = e.target;
|
|
var action = form.getAttribute('action');
|
|
if (action) {
|
|
action = toAbsoluteUrl(action);
|
|
if (shouldProxyUrl(action)) {
|
|
e.preventDefault();
|
|
var encodedUrl = encodeUrl(action);
|
|
form.action = '{{ route('proxy.browse') }}?url=' + encodedUrl;
|
|
form.submit();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Process existing anchor tags
|
|
function processExistingLinks() {
|
|
document.querySelectorAll('a[href]').forEach(function(link) {
|
|
var href = link.getAttribute('href');
|
|
if (href) {
|
|
href = toAbsoluteUrl(href);
|
|
if (shouldProxyUrl(href)) {
|
|
var encodedUrl = encodeUrl(href);
|
|
link.setAttribute('href', '{{ route('proxy.browse') }}?url=' + encodedUrl);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Process existing links immediately
|
|
processExistingLinks();
|
|
|
|
// Handle dynamically added links
|
|
var observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(mutation) {
|
|
mutation.addedNodes.forEach(function(node) {
|
|
if (node.nodeType === 1) { // Element node
|
|
if (node.tagName === 'A') {
|
|
var href = node.getAttribute('href');
|
|
if (href) {
|
|
href = toAbsoluteUrl(href);
|
|
if (shouldProxyUrl(href)) {
|
|
var encodedUrl = encodeUrl(href);
|
|
node.setAttribute('href', '{{ route('proxy.browse') }}?url=' + encodedUrl);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Start observing the document
|
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
});
|
|
</script>
|
|
EOT;
|
|
|
|
// Insert script before closing body tag
|
|
$content = preg_replace('/<\/body>/i', $script . '</body>', $content);
|
|
|
|
return $content;
|
|
}
|
|
|
|
private function replaceUrl($url)
|
|
{
|
|
$parsedUrl = parse_url($url);
|
|
|
|
if ($parsedUrl['host'] === 'www.youtube.com') {
|
|
$url = str_replace('www.youtube.com', 'tube.cadence.moe', $url);
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
}
|