From 6e85a18dab4a79ba66351fd712d2c7cbfab0629c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 6 Oct 2026 15:48:41 -0500 Subject: [PATCH 1/2] feat: expose public URL --- src/App.php | 26 ++++++---- src/Concerns/ResolveHost.php | 49 +++++++++++++++++++ src/Routing/UrlGenerator.php | 20 ++++---- .../Concerns/InteractWithResponses.php | 44 ++++++++++++++--- tests/Feature/AppTest.php | 29 ++++++++++- tests/Unit/Routing/UrlGeneratorTest.php | 24 ++++++++- tests/fixtures/application/config/app.php | 3 +- 7 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 src/Concerns/ResolveHost.php diff --git a/src/App.php b/src/App.php index 397d0044..3460294d 100644 --- a/src/App.php +++ b/src/App.php @@ -21,10 +21,10 @@ use Amp\Socket\ServerTlsContext; use Amp\Sync\LocalSemaphore; use League\Container\Container; -use League\Uri\Uri; use Mockery\LegacyMockInterface; use Mockery\MockInterface; use Monolog\Logger; +use Phenix\Concerns\ResolveHost; use Phenix\Console\Phenix; use Phenix\Constants\AppMode; use Phenix\Constants\ServerMode; @@ -51,11 +51,13 @@ class App implements AppContract, Makeable { + use ResolveHost; + protected static string $path; protected static Container $container; - protected string $host; + protected string $publicHost; protected RequestHandler $router; @@ -116,7 +118,7 @@ public function run(): void $this->detectProtocol(); - $this->host = Uri::new(Config::get('app.url'))->getHost(); + $this->publicHost = $this->resolvePublicHost(); $this->server = $this->createServer(); @@ -238,7 +240,7 @@ protected function setRouter(): void /** @var array $globalMiddlewares */ $globalMiddlewares = array_map(fn (string $middleware) => new $middleware(), $middlewares['global']); - $globalMiddlewares[] = SessionMiddlewareFactory::make($this->host); + $globalMiddlewares[] = SessionMiddlewareFactory::make($this->publicHost); $this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares); } @@ -326,7 +328,13 @@ protected function createClusterServer(): SocketHttpServer protected function expose(): void { - $port = (int) Config::get('app.port'); + $host = $this->resolveBindHost(); + $port = $this->resolveBindPort(); + + $address = str_contains($host, ':') && ! str_starts_with($host, '[') + ? "[{$host}]:{$port}" + : "{$host}:{$port}"; + $plainBindContext = (new BindContext())->withTcpNoDelay(); if ($this->protocol === Protocol::HTTPS) { @@ -337,21 +345,19 @@ protected function expose(): void (new ServerTlsContext())->withDefaultCertificate(new Certificate($certPath)) ); - $this->server->expose("{$this->host}:{$port}", $tlsBindContext); + $this->server->expose($address, $tlsBindContext); return; } - $this->server->expose("{$this->host}:{$port}", $plainBindContext); + $this->server->expose($address, $plainBindContext); } protected function detectProtocol(): void { - $url = (string) Config::get('app.url'); - /** @var string|null $certPath */ $certPath = Config::get('app.cert_path'); - $this->protocol = str_starts_with($url, 'https://') && $certPath !== null ? Protocol::HTTPS : Protocol::HTTP; + $this->protocol = $certPath !== null && $certPath !== '' ? Protocol::HTTPS : Protocol::HTTP; } } diff --git a/src/Concerns/ResolveHost.php b/src/Concerns/ResolveHost.php new file mode 100644 index 00000000..ec0a7d10 --- /dev/null +++ b/src/Concerns/ResolveHost.php @@ -0,0 +1,49 @@ +getHost(); + + if (! in_array($uri->getScheme(), ['http', 'https'], true) || $host === '') { + throw new RuntimeError('App URL must be an absolute HTTP or HTTPS URL with a host.'); + } + + return $host; + } + + protected function resolveBindHost(): string + { + $host = trim((string) Config::get('app.host')); + + if ($host === '') { + throw new RuntimeError('Bind host must not be empty.'); + } + + return trim($host, '[]'); + } + + protected function resolveBindPort(): int + { + $port = (int) Config::get('app.port'); + + if ($port < 1 || $port > 65535) { + throw new RuntimeError('Bind port must be between 1 and 65535.'); + } + + return $port; + } +} diff --git a/src/Routing/UrlGenerator.php b/src/Routing/UrlGenerator.php index 7949870e..f8499f64 100644 --- a/src/Routing/UrlGenerator.php +++ b/src/Routing/UrlGenerator.php @@ -13,6 +13,7 @@ use Phenix\Crypto\Bin2Base64; use Phenix\Facades\Config; use Phenix\Routing\Exceptions\RouteNotFoundException; +use Phenix\Util\Str; use function array_key_exists; @@ -50,16 +51,13 @@ public function route(BackedEnum|string $name, array $parameters = [], bool $abs public function to(string $path, array $parameters = [], bool $secure = false): string { - $path = trim($path, '/'); - $port = Config::get('app.port'); - - $url = Config::get('app.url'); + $url = (string) Config::get('app.url'); if ($secure) { $url = (string) preg_replace('/^http:/', 'https:', $url); } - $uri = "{$url}:{$port}/{$path}"; + $uri = $this->joinUrlAndPath($url, $path); if (! empty($parameters)) { $uri .= '?' . http_build_query($parameters); @@ -172,11 +170,8 @@ protected function substituteParameters(string $path, array &$parameters): strin */ protected function buildAbsoluteUrl(string $path, array $parameters = []): string { - $path = trim($path, '/'); - $port = Config::get('app.port'); - $url = Config::get('app.url'); - - $uri = "{$url}:{$port}/{$path}"; + $url = (string) Config::get('app.url'); + $uri = $this->joinUrlAndPath($url, $path); if (! empty($parameters)) { $uri .= '?' . http_build_query($parameters); @@ -185,6 +180,11 @@ protected function buildAbsoluteUrl(string $path, array $parameters = []): strin return $uri; } + protected function joinUrlAndPath(string $url, string $path): string + { + return Str::finish(rtrim($url, '/'), '/') . trim($path, '/'); + } + /** * Resolve an expiration value to a UNIX timestamp. */ diff --git a/src/Testing/Concerns/InteractWithResponses.php b/src/Testing/Concerns/InteractWithResponses.php index c51e9fbb..6759cb1c 100644 --- a/src/Testing/Concerns/InteractWithResponses.php +++ b/src/Testing/Concerns/InteractWithResponses.php @@ -16,10 +16,13 @@ use Amp\Socket\Socket; use Amp\Socket\SocketAddress; use Amp\Socket\SocketConnector; +use League\Uri\Uri; +use Phenix\Facades\Config; use Phenix\Facades\Url; use Phenix\Http\Constants\HttpMethod; use Phenix\Testing\TestResponse; +use function array_key_exists; use function is_array; trait InteractWithResponses @@ -40,9 +43,19 @@ public function call( Form|array|string|null $body = null, array $headers = [] ): TestResponse { + $publicUri = Uri::new($this->isAbsoluteUri($path) ? $path : Url::to($path, $parameters)); $uri = $this->resolveRequestUri($path, $parameters); $request = new Request($uri, $method->value); + if (! array_key_exists('Host', $headers) && ! array_key_exists('host', $headers)) { + $publicHost = $publicUri->getHost(); + $publicPort = $publicUri->getPort(); + + if ($publicHost !== '') { + $request->setHeader('Host', $publicHost . ($publicPort === null ? '' : ":{$publicPort}")); + } + } + if ($headers) { $request->setHeaders($headers); } @@ -150,21 +163,38 @@ public function options( private function resolveRequestUri(string $path, array $parameters = []): string { if (! $this->isAbsoluteUri($path)) { - return Url::to($path, $parameters); + $path = Url::to($path, $parameters); + $parameters = []; } - if (empty($parameters)) { - return $path; + $uri = Uri::new($path); + $bindHost = trim((string) Config::get('app.host', '127.0.0.1'), '[]'); + + if ($bindHost === '0.0.0.0') { + $bindHost = '127.0.0.1'; + } elseif ($bindHost === '::') { + $bindHost = '::1'; + } + + $host = str_contains($bindHost, ':') ? "[{$bindHost}]" : $bindHost; + $scheme = Config::get('app.cert_path') ? 'https' : 'http'; + $port = (int) Config::get('app.port', 1337); + $requestPath = $uri->getPath() ?: '/'; + $query = $uri->getQuery() ?? ''; + + if (! empty($parameters)) { + $query .= ($query === '' ? '' : '&') . http_build_query($parameters); } - return $path . (str_contains($path, '?') ? '&' : '?') . http_build_query($parameters); + return "{$scheme}://{$host}:{$port}{$requestPath}" . ($query === '' ? '' : "?{$query}"); } private function isAbsoluteUri(string $path): bool { - $scheme = parse_url($path, PHP_URL_SCHEME); - $host = parse_url($path, PHP_URL_HOST); + $uri = Uri::new($path); + $scheme = $uri->getScheme(); + $host = $uri->getHost(); - return is_string($scheme) && $scheme !== '' && is_string($host) && $host !== ''; + return $scheme !== null && $scheme !== '' && $host !== null && $host !== ''; } } diff --git a/tests/Feature/AppTest.php b/tests/Feature/AppTest.php index ae414587..14b78b7e 100644 --- a/tests/Feature/AppTest.php +++ b/tests/Feature/AppTest.php @@ -31,6 +31,21 @@ $this->app->stop(); }); +it('binds locally while using a different public URL', function (): void { + Config::set('app.url', 'https://public.example.com'); + Config::set('app.host', '127.0.0.1'); + + Route::get('/bind', fn (): Response => response()->json(['message' => 'Bound locally'])); + + $this->app->run(); + + $this->get('/bind') + ->assertOk() + ->assertJsonPath('message', 'Bound locally'); + + $this->app->stop(); +}); + it('starts server in proxied mode with no trusted proxies', function (): void { Config::set('app.app_mode', AppMode::PROXIED->value); @@ -38,7 +53,7 @@ })->throws(RuntimeError::class); it('starts server with TLS certificate', function (): void { - Config::set('app.url', 'https://127.0.0.1'); + Config::set('app.url', 'https://public.example.com'); Config::set('app.port', 1338); Config::set('app.cert_path', __DIR__ . '/../fixtures/files/cert.pem'); @@ -52,3 +67,15 @@ $this->app->stop(); }); + +it('rejects an invalid public URL', function (): void { + Config::set('app.url', 'not-an-absolute-url'); + + $this->app->run(); +})->throws(RuntimeError::class, 'App URL must be an absolute HTTP or HTTPS URL with a host.'); + +it('rejects an invalid bind port', function (): void { + Config::set('app.port', 70000); + + $this->app->run(); +})->throws(RuntimeError::class, 'Bind port must be between 1 and 65535.'); diff --git a/tests/Unit/Routing/UrlGeneratorTest.php b/tests/Unit/Routing/UrlGeneratorTest.php index c7f5b076..87cc69c8 100644 --- a/tests/Unit/Routing/UrlGeneratorTest.php +++ b/tests/Unit/Routing/UrlGeneratorTest.php @@ -16,8 +16,7 @@ beforeEach(function (): void { $this->key = Crypto::generateEncodedKey(); Config::set('app.key', $this->key); - Config::set('app.url', 'http://127.0.0.1'); - Config::set('app.port', 1337); + Config::set('app.url', 'http://127.0.0.1:1337'); }); function createRequest(string $url): Request @@ -77,6 +76,27 @@ public function getTlsInfo(): ?\Amp\Socket\TlsInfo expect($url)->toBe('http://127.0.0.1:1337/users'); }); +it('keeps the public URL independent from the bind address', function (): void { + Config::set('app.url', 'https://public.example.com'); + Config::set('app.host', '0.0.0.0'); + Config::set('app.port', 9000); + + $route = new Router(); + $route->get('/events', fn (): Response => response()->plain('Ok')) + ->name('events.index'); + + expect((new UrlGenerator($route))->route('events.index')) + ->toBe('https://public.example.com/events'); +}); + +it('preserves an explicit public port without adding the bind port', function (): void { + Config::set('app.url', 'https://public.example.com:8443/base///'); + Config::set('app.port', 9000); + + expect((new UrlGenerator(new Router()))->to('/events')) + ->toBe('https://public.example.com:8443/base/events'); +}); + it('generates a URL for a named route using helper', function (): void { RouteFacade::get('/users', fn (): Response => response()->plain('Ok')) ->name('users.index'); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index ae739dec..ae1f227f 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -5,7 +5,8 @@ return [ 'name' => env('APP_NAME', static fn (): string => 'Phenix'), 'env' => env('APP_ENV', static fn (): string => 'local'), - 'url' => env('APP_URL', static fn (): string => 'http://127.0.0.1'), + 'url' => env('APP_URL', static fn (): string => 'http://127.0.0.1:1337'), + 'host' => env('APP_HOST', static fn (): string => '127.0.0.1'), 'port' => env('APP_PORT', static fn (): int => 1337), 'cert_path' => env('APP_CERT_PATH', static fn (): string|null => null), 'key' => env('APP_KEY'), From aa615053902e1cbe9c747e5fc94f86738661c0ba Mon Sep 17 00:00:00 2001 From: "Omar A. Barbosa Ortiz" Date: Tue, 6 Oct 2026 16:20:54 -0500 Subject: [PATCH 2/2] chore: remove phpstan ignore comment in expect method Removed phpstan ignore comment from expect method. --- src/Testing/Mockery.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Testing/Mockery.php b/src/Testing/Mockery.php index 1c36c1d6..30333bd5 100644 --- a/src/Testing/Mockery.php +++ b/src/Testing/Mockery.php @@ -44,7 +44,6 @@ public function __construct(string|object $object) public function expect(callable ...$methods) { foreach ($methods as $method => $expectation) { - /** @phpstan-ignore-next-line */ $method = $this->mock ->shouldReceive((string) $method) ->atLeast()