Skip to content
Merged
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
26 changes: 16 additions & 10 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -238,7 +240,7 @@ protected function setRouter(): void
/** @var array<int, Middleware> $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);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
}
49 changes: 49 additions & 0 deletions src/Concerns/ResolveHost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Phenix\Concerns;

use League\Uri\Uri;
use Phenix\Exceptions\RuntimeError;
use Phenix\Facades\Config;

use function in_array;

trait ResolveHost
{
protected function resolvePublicHost(): string
{
$url = (string) Config::get('app.url');
$uri = Uri::new($url);
$host = $uri->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;
}
}
20 changes: 10 additions & 10 deletions src/Routing/UrlGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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.
*/
Expand Down
44 changes: 37 additions & 7 deletions src/Testing/Concerns/InteractWithResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 !== '';
}
}
1 change: 0 additions & 1 deletion src/Testing/Mockery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
29 changes: 28 additions & 1 deletion tests/Feature/AppTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,29 @@
$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);

$this->app->run();
})->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');

Expand All @@ -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.');
24 changes: 22 additions & 2 deletions tests/Unit/Routing/UrlGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion tests/fixtures/application/config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading