Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/Factory/UriBuilderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
);
}
}
84 changes: 83 additions & 1 deletion src/Uri/UriBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
163 changes: 163 additions & 0 deletions test/Unit/Uri/UriBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
Loading