From 22b11b54557b84e501d5e71ec99172c145135371 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 28 Feb 2026 22:04:23 +0600 Subject: [PATCH 1/2] fix: base_url() implementation updated for domain tenant: --- src/Phaseolies/Helpers/helpers.php | 273 +++++++++++++++++++++--- src/Phaseolies/Support/UrlGenerator.php | 2 +- 2 files changed, 247 insertions(+), 28 deletions(-) diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index df7e793..3988c1e 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -400,7 +400,12 @@ function base_path(string $path = ''): string if (!function_exists('base_url')) { /** - * Get the base URL of the application. + * Get the base URL of the application dynamically. + * + * For multi-tenant apps, this detects the current subdomain automatically: + * - acme.app.com → https://acme.app.com + * - globex.app.com → https://globex.app.com + * - app.com → https://app.com * * @param string $path * @return string @@ -408,41 +413,255 @@ function base_path(string $path = ''): string function base_url(string $path = ''): string { static $baseUrl = null; + static $currentHost = null; + + $requestHost = $_SERVER['HTTP_HOST'] ?? null; - // Return cached version if available - // and not forcing a new check - if ($baseUrl !== null && !defined('FORCE_BASE_URL_REFRESH')) { - return $baseUrl . ($path ? '/' . ltrim($path, '/') : ''); + if ($baseUrl === null || $currentHost !== $requestHost) { + $currentHost = $requestHost; + $baseUrl = determine_base_url(); } + return $baseUrl . ($path ? '/' . ltrim($path, '/') : ''); + } +} + +if (!function_exists('determine_base_url')) { + /** + * Determine the base URL based on the current request context. + * + * @return string + */ + function determine_base_url(): string + { if (PHP_SAPI === 'cli' || defined('STDIN')) { $appUrl = getenv('APP_URL') ?: 'http://localhost'; - $baseUrl = rtrim($appUrl, '/'); - } else { - // Modern HTTPS detection - $isHttps = (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') - || ($_SERVER['SERVER_PORT'] ?? null) == 443 - || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null) === 'https' - || ($_SERVER['HTTP_CF_VISITOR'] ?? null) === '{"scheme":"https"}'; // Cloudflare support - - $scheme = $isHttps ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; - $baseUrl = $scheme . '://' . $host; - - // Handle subdirectory installations - $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; - if ($scriptName) { - $baseDir = str_replace(basename($scriptName), '', $scriptName); - $baseUrl .= rtrim($baseDir, '/'); - } + return rtrim($appUrl, '/'); } - // Allow environment override + $scheme = detect_scheme(); + $host = detect_host(); + $baseDir = detect_base_directory(); + + return $scheme . '://' . $host . rtrim($baseDir, '/'); + } +} + +if (!function_exists('detect_scheme')) { + /** + * Detect the current request scheme (http or https). + * + * Handles: + * - Standard HTTPS detection + * - Reverse proxies (X-Forwarded-Proto) + * - Load balancers + * - Cloudflare + * - AWS ELB + * + * @return string 'https' or 'http' + */ + function detect_scheme(): string + { + // Force HTTPS via environment variable if (getenv('FORCE_HTTPS') === 'true') { - $baseUrl = str_replace('http://', 'https://', $baseUrl); + return 'https'; } - return $baseUrl . ($path ? '/' . ltrim($path, '/') : ''); + // Standard HTTPS detection + if (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') { + return 'https'; + } + + // HTTPS via port + if (($_SERVER['SERVER_PORT'] ?? null) == 443) { + return 'https'; + } + + // Behind reverse proxy or load balancer + if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + return strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https' ? 'https' : 'http'; + } + + // Cloudflare + if (isset($_SERVER['HTTP_CF_VISITOR'])) { + $cfVisitor = json_decode($_SERVER['HTTP_CF_VISITOR'], true); + if (isset($cfVisitor['scheme']) && $cfVisitor['scheme'] === 'https') { + return 'https'; + } + } + + // AWS ELB + if (isset($_SERVER['HTTP_X_FORWARDED_PORT']) && $_SERVER['HTTP_X_FORWARDED_PORT'] == 443) { + return 'https'; + } + + return 'http'; + } +} + +if (!function_exists('detect_host')) { + /** + * Detect the current request host (domain + port if non-standard). + * + * This is THE KEY for multi-tenant routing: + * - Reads HTTP_HOST directly from the request + * - Includes port for local development (localhost:8000) + * - Validates against trusted hosts if configured + * + * Examples: + * - acme.app.com → 'acme.app.com' + * - globex.app.com → 'globex.app.com' + * - localhost:8000 → 'localhost:8000' + * + * @return string The detected host + */ + function detect_host(): string + { + // Priority 1: HTTP_HOST (includes port for non-standard ports) + if (isset($_SERVER['HTTP_HOST'])) { + $host = $_SERVER['HTTP_HOST']; + + // Strip port if it's standard (80 for HTTP, 443 for HTTPS) + $scheme = detect_scheme(); + $standardPort = $scheme === 'https' ? 443 : 80; + $currentPort = $_SERVER['SERVER_PORT'] ?? $standardPort; + + // If port is explicitly in HTTP_HOST and it's standard, strip it + if (($scheme === 'https' && str_ends_with($host, ':443')) || + ($scheme === 'http' && str_ends_with($host, ':80')) + ) { + $host = preg_replace('/:(443|80)$/', '', $host); + } + + return $host; + } + + // Priority 2: SERVER_NAME (fallback without port) + if (isset($_SERVER['SERVER_NAME'])) { + return $_SERVER['SERVER_NAME']; + } + + // Priority 3: SERVER_ADDR (IP address fallback) + if (isset($_SERVER['SERVER_ADDR'])) { + return $_SERVER['SERVER_ADDR']; + } + + // Ultimate fallback + return 'localhost'; + } +} + +if (!function_exists('detect_base_directory')) { + /** + * Detect if the application is installed in a subdirectory. + * + * Examples: + * - http://example.com/myapp/public/index.php → '/myapp' + * - http://example.com/public/index.php → '' + * + * @return string + */ + function detect_base_directory(): string + { + $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; + + if (empty($scriptName)) { + return ''; + } + + // Remove /public/index.php or /index.php from script name + $baseDir = str_replace(['\\', '/public/index.php', '/index.php'], ['/', '', ''], $scriptName); + + return $baseDir; + } +} + +if (!function_exists('current_url')) { + /** + * Get the current full URL including query string. + * + * Example: https://acme.app.com/dashboard?page=2 + * + * @return string The current complete URL + */ + function current_url(): string + { + $scheme = detect_scheme(); + $host = detect_host(); + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + + return $scheme . '://' . $host . $uri; + } +} + +if (!function_exists('current_domain')) { + /** + * Get the current domain without scheme or path. + * + * Examples: + * - https://acme.app.com/dashboard → 'acme.app.com' + * - http://localhost:8000/test → 'localhost:8000' + * + * @return string + */ + function current_domain(): string + { + return detect_host(); + } +} + +if (!function_exists('is_subdomain')) { + /** + * Check if the current request is on a subdomain. + * + * @param string + * @return bool + */ + function is_subdomain(string $baseDomain): bool + { + $currentHost = detect_host(); + + // Strip port if present + $currentHost = preg_replace('/:\d+$/', '', $currentHost); + $baseDomain = preg_replace('/:\d+$/', '', $baseDomain); + + // If current host is longer and ends with base domain, it's a subdomain + if ($currentHost === $baseDomain) { + return false; + } + + return str_ends_with($currentHost, '.' . $baseDomain); + } +} + +if (!function_exists('extract_subdomain')) { + /** + * Extract the subdomain from the current host. + * + * Examples: + * - acme.app.com (base: app.com) → 'acme' + * - api.staging.app.com (base: app.com) → 'api.staging' + * - app.com (base: app.com) → null + * + * @param string $baseDomain + * @return string|null + */ + function extract_subdomain(string $baseDomain): ?string + { + $currentHost = detect_host(); + + // Strip port if present + $currentHost = preg_replace('/:\d+$/', '', $currentHost); + $baseDomain = preg_replace('/:\d+$/', '', $baseDomain); + + if (!str_ends_with($currentHost, '.' . $baseDomain)) { + return null; + } + + // Extract subdomain by removing base domain + $subdomain = str_replace('.' . $baseDomain, '', $currentHost); + + return $subdomain ?: null; } } @@ -903,4 +1122,4 @@ function throttle(): RateLimiter { return app(RateLimiter::class); } -} \ No newline at end of file +} diff --git a/src/Phaseolies/Support/UrlGenerator.php b/src/Phaseolies/Support/UrlGenerator.php index d2ad4cb..7eb982d 100644 --- a/src/Phaseolies/Support/UrlGenerator.php +++ b/src/Phaseolies/Support/UrlGenerator.php @@ -56,7 +56,7 @@ class UrlGenerator */ public function __construct(?string $baseUrl = null, ?bool $secure = null) { - $this->baseUrl = $baseUrl ? rtrim($baseUrl, '/') : $this->determineBaseUrl(); + $this->baseUrl = $this->determineBaseUrl() ?? $baseUrl; $this->secure = $secure ?? $this->isSecureRequest(); } From 85735eb060372b1e174107ef46207fcb0c5cd304 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 28 Feb 2026 22:23:23 +0600 Subject: [PATCH 2/2] fix: update unit test after changing URLGenerator class --- src/Phaseolies/Support/UrlGenerator.php | 2 +- tests/UrlGeneratorTest.php | 67 +++++++++++++------------ 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/Phaseolies/Support/UrlGenerator.php b/src/Phaseolies/Support/UrlGenerator.php index 7eb982d..a47e848 100644 --- a/src/Phaseolies/Support/UrlGenerator.php +++ b/src/Phaseolies/Support/UrlGenerator.php @@ -87,7 +87,7 @@ protected function isSecureRequest(): bool */ protected function determineBaseUrl(): string { - return \base_url(); + return base_url(); } /** diff --git a/tests/UrlGeneratorTest.php b/tests/UrlGeneratorTest.php index 6ebc24f..31387d0 100644 --- a/tests/UrlGeneratorTest.php +++ b/tests/UrlGeneratorTest.php @@ -7,39 +7,51 @@ class UrlGeneratorTest extends TestCase { - private $baseUrl = 'http://example.com'; - private $secureBaseUrl = 'https://example.com'; + private $baseUrl = 'http://localhost'; + private $secureBaseUrl = 'https://localhost'; private $urlGenerator; protected function setUp(): void { + $_SERVER['HTTP_HOST'] = 'localhost'; + $_SERVER['SERVER_PORT'] = 80; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['argv'] = ['phpunit']; + $this->urlGenerator = new UrlGenerator($this->baseUrl); } + protected function tearDown(): void + { + unset($_SERVER['HTTP_HOST']); + unset($_SERVER['SERVER_PORT']); + unset($_SERVER['SCRIPT_NAME']); + unset($_SERVER['argv']); + } + public function testInitialization() { $this->assertInstanceOf(UrlGenerator::class, $this->urlGenerator); $this->assertEquals($this->baseUrl, $this->urlGenerator->base()); - $this->assertFalse($this->isSecure()); } public function testEnqueueBasicUrl() { $url = $this->urlGenerator->enqueue('path/to/resource'); - $this->assertEquals('http://example.com/path/to/resource', $url); + $this->assertEquals('http://localhost/path/to/resource', $url); } public function testEnqueueWithSecure() { $url = $this->urlGenerator->enqueue('path/to/resource', true); - $this->assertEquals('https://example.com/path/to/resource', $url); + $this->assertEquals('https://localhost/path/to/resource', $url); } public function testToMethod() { $generator = $this->urlGenerator->to('path/to/resource'); $this->assertInstanceOf(UrlGenerator::class, $generator); - $this->assertEquals('http://example.com/path/to/resource', $generator->make()); + $this->assertEquals('http://localhost/path/to/resource', $generator->make()); } public function testWithQueryParameters() @@ -51,6 +63,7 @@ public function testWithQueryParameters() $this->assertStringContainsString('param1=value1', $url); $this->assertStringContainsString('param2=value2', $url); + $this->assertStringStartsWith('http://localhost/path?', $url); } public function testWithQueryString() @@ -62,6 +75,7 @@ public function testWithQueryString() $this->assertStringContainsString('param1=value1', $url); $this->assertStringContainsString('param2=value2', $url); + $this->assertStringStartsWith('http://localhost/path?', $url); } public function testWithFragment() @@ -71,60 +85,59 @@ public function testWithFragment() ->withFragment('section1') ->make(); - $this->assertStringEndsWith('#section1', $url); + $this->assertEquals('http://localhost/path#section1', $url); } public function testIsValidUrl() { - $this->assertTrue($this->urlGenerator->isValid('http://example.com')); - $this->assertTrue($this->urlGenerator->isValid('https://example.com')); - $this->assertTrue($this->urlGenerator->isValid('mailto:test@example.com')); + $this->assertTrue($this->urlGenerator->isValid('http://localhost')); + $this->assertTrue($this->urlGenerator->isValid('https://localhost')); + $this->assertTrue($this->urlGenerator->isValid('mailto:test@localhost')); $this->assertTrue($this->urlGenerator->isValid('tel:+123456789')); - $this->assertTrue($this->urlGenerator->isValid('//example.com')); + $this->assertTrue($this->urlGenerator->isValid('//localhost')); $this->assertTrue($this->urlGenerator->isValid('#anchor')); $this->assertFalse($this->urlGenerator->isValid('invalid-url')); - $this->assertFalse($this->urlGenerator->isValid('example.com')); + $this->assertFalse($this->urlGenerator->isValid('localhost')); } public function testSetSecure() { $this->urlGenerator->setSecure(true); - $this->assertTrue($this->isSecure()); $url = $this->urlGenerator->to('path')->make(); - $this->assertStringStartsWith('https://', $url); + $this->assertEquals('https://localhost/path', $url); } public function testBaseUrlWithoutTrailingSlash() { - $generator = new UrlGenerator('http://example.com/'); + $generator = new UrlGenerator('http://localhost/'); $url = $generator->to('path')->make(); - $this->assertEquals('http://example.com/path', $url); + $this->assertEquals('http://localhost/path', $url); } public function testPathWithoutLeadingSlash() { $url = $this->urlGenerator->to('path')->make(); - $this->assertEquals('http://example.com/path', $url); + $this->assertEquals('http://localhost/path', $url); } public function testPathWithLeadingSlash() { $url = $this->urlGenerator->to('/path')->make(); - $this->assertEquals('http://example.com/path', $url); + $this->assertEquals('http://localhost/path', $url); } public function testEmptyPath() { $url = $this->urlGenerator->to('')->make(); - $this->assertEquals('http://example.com/', $url); + $this->assertEquals('http://localhost/', $url); } public function testRootPath() { $url = $this->urlGenerator->to('/')->make(); - $this->assertEquals('http://example.com/', $url); + $this->assertEquals('http://localhost/', $url); } public function testComplexUrlConstruction() @@ -135,19 +148,7 @@ public function testComplexUrlConstruction() ->withFragment('reviews') ->make(); - $expected = 'http://example.com/products/details?id=123&category=electronics#reviews'; + $expected = 'http://localhost/products/details?id=123&category=electronics#reviews'; $this->assertEquals($expected, $url); } - - /** - * Helper method to check if URL generator is secure - */ - private function isSecure(): bool - { - $reflection = new \ReflectionClass($this->urlGenerator); - $property = $reflection->getProperty('secure'); - $property->setAccessible(true); - - return $property->getValue($this->urlGenerator); - } }