diff --git a/src/router/src/Contracts/UrlGenerator.php b/src/router/src/Contracts/UrlGenerator.php index e709b32e5..9afab98a3 100644 --- a/src/router/src/Contracts/UrlGenerator.php +++ b/src/router/src/Contracts/UrlGenerator.php @@ -129,6 +129,11 @@ public function forceScheme(?string $scheme): void; */ public function forceHttps(bool $force = true): void; + /** + * Set the URL origin for all generated URLs. + */ + public function useOrigin(?string $root): void; + /** * Set a callback to be used to format the host of generated URLs. */ diff --git a/src/router/src/UrlGenerator.php b/src/router/src/UrlGenerator.php index e9583b2a7..27afc651f 100644 --- a/src/router/src/UrlGenerator.php +++ b/src/router/src/UrlGenerator.php @@ -404,6 +404,23 @@ public function forceHttps(bool $force = true): void } } + /** + * Set the URL origin for all generated URLs. + * + * This is stored in coroutine Context for request isolation. + */ + public function useOrigin(?string $root): void + { + if ($root !== null) { + Context::set('__url.forced_root', rtrim($root, '/')); + } else { + Context::destroy('__url.forced_root'); + } + + // Clear the cached root so it will be recalculated + Context::destroy('__request.root.uri'); + } + /** * Set a callback to be used to format the host of generated URLs. */ @@ -469,6 +486,16 @@ protected function getSignedKey(): string protected function getRootUrl(string $scheme): string { + // Check for forced root first + $forcedRoot = Context::get('__url.forced_root'); + if ($forcedRoot !== null) { + $root = new Uri($forcedRoot); + + return $root->withScheme( + str_replace('://', '', $scheme) + )->toString(); + } + $root = Context::getOrSet('__request.root.uri', function () { $requestUri = $this->getRequestUri()->toString(); $root = preg_replace(';^([^:]+://[^/?#]+).*$;', '\1', $requestUri); diff --git a/tests/Router/UrlGeneratorTest.php b/tests/Router/UrlGeneratorTest.php index fa1e94584..ac19122fe 100644 --- a/tests/Router/UrlGeneratorTest.php +++ b/tests/Router/UrlGeneratorTest.php @@ -52,6 +52,7 @@ protected function tearDown(): void parent::tearDown(); Context::destroy('__request.root.uri'); + Context::destroy('__url.forced_root'); Context::destroy(ServerRequestInterface::class); } @@ -554,6 +555,89 @@ public function testSignedUrlParameterCannotBeNamedExpires() Request::create($urlGenerator->signedRoute('foo', ['expires' => 253402300799])); } + public function testUseOrigin() + { + $this->mockRequest(); + + $urlGenerator = new UrlGenerator($this->container); + + // Test original URL + $this->assertEquals('http://example.com/foo', $urlGenerator->to('foo')); + + // Test forcing root URL + $urlGenerator->useOrigin('https://tenant.example.com'); + $this->assertEquals('http://tenant.example.com/foo', $urlGenerator->to('foo')); + + // Test with secure + $this->assertEquals('https://tenant.example.com/foo', $urlGenerator->to('foo', [], true)); + + // Test forcing different root + $urlGenerator->useOrigin('https://other-tenant.example.com'); + $this->assertEquals('http://other-tenant.example.com/bar', $urlGenerator->to('bar')); + + // Test clearing forced root (passing null) + $urlGenerator->useOrigin(null); + $this->assertEquals('http://example.com/baz', $urlGenerator->to('baz')); + } + + public function testUseOriginWithTrailingSlash() + { + $this->mockRequest(); + + $urlGenerator = new UrlGenerator($this->container); + + // Test forcing root URL with trailing slash (should be trimmed) + $urlGenerator->useOrigin('https://tenant.example.com/'); + $this->assertEquals('http://tenant.example.com/foo', $urlGenerator->to('foo')); + } + + public function testUseOriginWithSchemeOverride() + { + $this->mockRequest(); + + $urlGenerator = new UrlGenerator($this->container); + + // Force root with https, but use http scheme in to() + $urlGenerator->useOrigin('https://tenant.example.com'); + $this->assertEquals('http://tenant.example.com/foo', $urlGenerator->to('foo', [], false)); + + // Force root with http, but use https scheme in to() + $urlGenerator->useOrigin('http://tenant.example.com'); + $this->assertEquals('https://tenant.example.com/foo', $urlGenerator->to('foo', [], true)); + } + + public function testUseOriginAffectsAsset() + { + $this->mockRequest(); + + $urlGenerator = new UrlGenerator($this->container); + + // Test original asset URL + $this->assertEquals('http://example.com/css/app.css', $urlGenerator->asset('css/app.css')); + + // Test forcing root URL affects asset generation + $urlGenerator->useOrigin('https://tenant.example.com'); + $this->assertEquals('http://tenant.example.com/css/app.css', $urlGenerator->asset('css/app.css')); + } + + public function testUseOriginClearsCache() + { + $this->mockRequest(); + + $urlGenerator = new UrlGenerator($this->container); + + // Generate a URL to populate the cache + $this->assertEquals('http://example.com/foo', $urlGenerator->to('foo')); + $this->assertNotNull(Context::get('__request.root.uri')); + + // useOrigin should clear the cache + $urlGenerator->useOrigin('https://tenant.example.com'); + $this->assertNull(Context::get('__request.root.uri')); + + // New URL should use forced root + $this->assertEquals('http://tenant.example.com/bar', $urlGenerator->to('bar')); + } + private function mockContainer() { /** @var ContainerInterface|MockInterface */