diff --git a/src/Factory/UriBuilderFactory.php b/src/Factory/UriBuilderFactory.php index f301a162..c884b84c 100644 --- a/src/Factory/UriBuilderFactory.php +++ b/src/Factory/UriBuilderFactory.php @@ -16,6 +16,7 @@ namespace Horde\Core\Factory; +use Horde\Core\Config\ConfigLoader; use Horde\Core\Config\RegistryConfigLoader; use Horde\Core\Uri\RouteMapperProvider; use Horde\Core\Uri\UriBuilder; @@ -39,6 +40,17 @@ public function create(Injector $injector): UriBuilderInterface } catch (Throwable) { } - return new UriBuilder($registryState, $routeProvider, $request); + $configState = null; + try { + $configState = $injector->getInstance(ConfigLoader::class)->load('horde'); + } catch (Throwable) { + } + + return new UriBuilder( + $registryState, + $routeProvider, + request: $request, + configState: $configState, + ); } } diff --git a/src/Uri/UriBuilder.php b/src/Uri/UriBuilder.php index a5bc0b49..69316f87 100644 --- a/src/Uri/UriBuilder.php +++ b/src/Uri/UriBuilder.php @@ -16,6 +16,7 @@ namespace Horde\Core\Uri; +use Horde\Core\Config\State; use Horde\Core\Config\RegistryState; use Horde\Http\Uri; use Horde\Url\Psr7Bridge; @@ -25,14 +26,25 @@ class UriBuilder extends Uri implements UriBuilderInterface { + /** + * $conf['use_ssl'] modes, mirroring Horde\Core\Horde::SSL_*. + */ + private const SSL_NEVER = 0; + private const SSL_ALWAYS = 1; + private const SSL_AUTO = 2; + + private const STANDARD_PORTS = ['http' => 80, 'https' => 443]; + private RegistryState $registryState; private RouteMapperProvider $routeProvider; + private ?State $configState; public function __construct( RegistryState $registryState, RouteMapperProvider $routeProvider, ?ServerRequestInterface $request = null, string $uri = '', + ?State $configState = null, ) { if ($request !== null && $uri === '') { $requestUri = $request->getUri(); @@ -50,6 +62,7 @@ public function __construct( parent::__construct($uri); $this->registryState = $registryState; $this->routeProvider = $routeProvider; + $this->configState = $configState; } // PSR-7 with* overrides — narrow return type from self to static @@ -210,7 +223,7 @@ private function resolveKey(string $app, string $key, string $default): string private function applyBase(string $base): static { if (preg_match('#^[a-z][a-z0-9+.\-]*://#i', $base) !== 1) { - return $this->withPath($base); + return $this->applyConfiguredAuthority()->withPath($base); } $parsed = new Uri($base); @@ -229,6 +242,75 @@ private function applyBase(string $base): static return $clone->withPath($parsed->getPath()); } + /** + * Reconcile scheme/host/port against conf.php for a path-only base. + * + * When a registry value is path-only (no fully qualified base URL), the + * absolute components come from conf.php, mirroring legacy Horde::url(): + * + * - `use_ssl = SSL_ALWAYS` forces https; `SSL_NEVER` forces http; + * `SSL_AUTO` (and any other value) leaves the request-derived scheme + * alone so the request has the last word. + * - `server.name` supplies the host in every mode when set. + * - `server.port` supplies the port, but a port that is standard for the + * resolved scheme (80/443) is dropped so it never renders. + * + * Without an injected config state the builder keeps its prior behaviour + * and reflects only the incoming request. + */ + private function applyConfiguredAuthority(): static + { + if ($this->configState === null) { + return $this; + } + + $clone = $this; + + $useSsl = (int) ($this->configState->get('use_ssl', self::SSL_NEVER) ?? self::SSL_NEVER); + $schemeForced = false; + if ($useSsl === self::SSL_ALWAYS) { + $clone = $clone->withScheme('https'); + $schemeForced = true; + } elseif ($useSsl === self::SSL_NEVER) { + $clone = $clone->withScheme('http'); + $schemeForced = true; + } + + $serverName = (string) ($this->configState->get('server.name', '') ?? ''); + if ($serverName !== '') { + $clone = $clone->withHost($serverName); + } + + $serverPort = $this->configState->get('server.port'); + if ($serverPort !== null && $serverPort !== '') { + $clone = $clone->withPort((int) $serverPort); + } elseif ($schemeForced) { + // The request-inherited port belonged to the request scheme, which + // config has just overridden; without a configured port it is + // meaningless for the forced scheme, so drop it. + $clone = $clone->withPort(null); + } + + return $clone->stripStandardPort(); + } + + /** + * Drop the port when it is the standard port for the current scheme. + */ + private function stripStandardPort(): static + { + $port = $this->getPort(); + if ($port === null) { + return $this; + } + $scheme = $this->getScheme(); + if (isset(self::STANDARD_PORTS[$scheme]) && self::STANDARD_PORTS[$scheme] === $port) { + return $this->withPort(null); + } + + return $this; + } + private static function normalizePath(string $path): string { return (string) preg_replace('#/{2,}#', '/', $path); diff --git a/test/Unit/Uri/UriBuilderTest.php b/test/Unit/Uri/UriBuilderTest.php index 037930cc..2d18438c 100644 --- a/test/Unit/Uri/UriBuilderTest.php +++ b/test/Unit/Uri/UriBuilderTest.php @@ -5,6 +5,7 @@ namespace Horde\Core\Test\Unit\Uri; use Horde\Core\Config\RegistryState; +use Horde\Core\Config\State; use Horde\Core\Uri\RouteMapperProvider; use Horde\Core\Uri\UriBuilder; use Horde\Core\Uri\UriBuilderInterface; @@ -578,4 +579,166 @@ public function getMapper(string $app): ?Mapper ); self::assertInstanceOf(UriBuilder::class, $result); } + + // --- Config-driven authority for path-only registry values (base#150) --- + + private function requestFor(string $scheme, string $authority): ServerRequestInterface + { + $requestUri = $this->createMock(UriInterface::class); + $requestUri->expects($this->atLeastOnce())->method('getScheme')->willReturn($scheme); + $requestUri->expects($this->atLeastOnce())->method('getAuthority')->willReturn($authority); + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->atLeastOnce())->method('getUri')->willReturn($requestUri); + + return $request; + } + + #[Test] + public function sslAlwaysForcesHttpsAndStripsStandardPortBehindProxy(): void + { + // Proxy terminates SSL; backend request arrives as http on port 80. + $request = $this->requestFor('http', 'mailhost.example.com:80'); + $config = new State([ + 'use_ssl' => 1, + 'server' => ['name' => 'mailhost.example.com', 'port' => 443], + ]); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde')->withPart('admin/config/config.php'); + + self::assertSame( + 'https://mailhost.example.com/horde/admin/config/config.php', + (string) $result + ); + self::assertNull($result->getPort()); + } + + #[Test] + public function sslNeverForcesHttpAndStripsStandardPort(): void + { + $request = $this->requestFor('https', 'mailhost.example.com:443'); + $config = new State([ + 'use_ssl' => 0, + 'server' => ['name' => 'mailhost.example.com', 'port' => 80], + ]); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde'); + + self::assertSame('http', $result->getScheme()); + self::assertNull($result->getPort()); + self::assertSame('http://mailhost.example.com/horde', (string) $result); + } + + #[Test] + public function sslAutoLetsRequestDecideScheme(): void + { + $request = $this->requestFor('https', 'front.example.com'); + $config = new State([ + 'use_ssl' => 2, + 'server' => ['name' => 'front.example.com', 'port' => 443], + ]); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde'); + + self::assertSame('https', $result->getScheme()); + self::assertSame('https://front.example.com/horde', (string) $result); + } + + #[Test] + public function sslAutoKeepsPlainRequestScheme(): void + { + $request = $this->requestFor('http', 'front.example.com'); + $config = new State([ + 'use_ssl' => 2, + 'server' => ['name' => 'front.example.com'], + ]); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde'); + + self::assertSame('http', $result->getScheme()); + self::assertSame('http://front.example.com/horde', (string) $result); + } + + #[Test] + public function nonStandardConfiguredPortIsRetained(): void + { + $request = $this->requestFor('http', 'mailhost.example.com:80'); + $config = new State([ + 'use_ssl' => 1, + 'server' => ['name' => 'mailhost.example.com', 'port' => 8443], + ]); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde'); + + self::assertSame(8443, $result->getPort()); + self::assertSame('https://mailhost.example.com:8443/horde', (string) $result); + } + + #[Test] + public function configuredServerNameOverridesRequestHost(): void + { + $request = $this->requestFor('https', 'internal-node1:443'); + $config = new State([ + 'use_ssl' => 1, + 'server' => ['name' => 'mail.example.com', 'port' => 443], + ]); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde'); + + self::assertSame('mail.example.com', $result->getHost()); + } + + #[Test] + public function absoluteRegistryUrlWinsOverConfig(): void + { + $registry = new RegistryState([ + 'horde' => ['webroot' => 'https://assets.example.com:8443/horde'], + ]); + $request = $this->requestFor('http', 'mailhost.example.com:80'); + $config = new State([ + 'use_ssl' => 0, + 'server' => ['name' => 'mailhost.example.com', 'port' => 80], + ]); + + $builder = new UriBuilder($registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde'); + + self::assertSame('https', $result->getScheme()); + self::assertSame('assets.example.com', $result->getHost()); + self::assertSame(8443, $result->getPort()); + self::assertSame('https://assets.example.com:8443/horde', (string) $result); + } + + #[Test] + public function withoutConfigStateBehaviourIsUnchanged(): void + { + // No config state: reflect the request verbatim, including :80. + $request = $this->requestFor('http', 'mailhost.example.com:80'); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request); + $result = $builder->withAppWebroot('horde'); + + self::assertSame('http', $result->getScheme()); + self::assertSame(80, $result->getPort()); + self::assertSame('http://mailhost.example.com:80/horde', (string) $result); + } + + #[Test] + public function configWithoutServerNameKeepsRequestHost(): void + { + $request = $this->requestFor('http', 'mailhost.example.com:80'); + $config = new State(['use_ssl' => 1]); + + $builder = new UriBuilder($this->registry, $this->routeProvider, $request, '', $config); + $result = $builder->withAppWebroot('horde'); + + self::assertSame('https', $result->getScheme()); + self::assertSame('mailhost.example.com', $result->getHost()); + self::assertNull($result->getPort()); + } }