diff --git a/composer.json b/composer.json index 40e0392d..fdd1b2ce 100644 --- a/composer.json +++ b/composer.json @@ -10,17 +10,17 @@ ], "license": "MIT", "minimum-stability": "stable", - "autoload": { + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "autoload-dev": { "psr-4": { - "Utopia\\": "src/", - "Tests\\E2E\\": "tests/e2e" - } - }, - "autoload-dev": { - "psr-4": { - "Utopia\\Http\\Tests\\": "tests/" - } - }, + "Utopia\\Http\\Tests\\": "tests/", + "Tests\\E2E\\": "tests/e2e" + } + }, "scripts": { "lint": "vendor/bin/pint --test", "format": "vendor/bin/pint", @@ -30,9 +30,11 @@ }, "require": { "php": ">=8.2", + "ext-swoole": "*", "utopia-php/di": "0.3.*", - "utopia-php/validators": "0.2.*", - "ext-swoole": "*" + "utopia-php/servers": "0.3.*", + "utopia-php/compression": "0.1.*", + "utopia-php/validators": "0.2.*" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index c854c2ab..2bebe141 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5afd948989df91d546b1694354e2f9d2", + "content-hash": "b50963daa3cd55d8307513e3044491ae", "packages": [ { "name": "psr/container", @@ -59,6 +59,52 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "utopia-php/compression", + "version": "0.1.4", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/compression.git", + "reference": "68045cb9d714c1259582d2dfd0e76bd34f83e713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/compression/zipball/68045cb9d714c1259582d2dfd0e76bd34f83e713", + "reference": "68045cb9d714c1259582d2dfd0e76bd34f83e713", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "laravel/pint": "1.2.*", + "phpunit/phpunit": "^9.3", + "vimeo/psalm": "4.0.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Compression\\": "src/Compression" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple Compression library to handle file compression", + "keywords": [ + "compression", + "framework", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/compression/issues", + "source": "https://github.com/utopia-php/compression/tree/0.1.4" + }, + "time": "2026-02-17T05:53:40+00:00" + }, { "name": "utopia-php/di", "version": "0.3.1", @@ -110,6 +156,60 @@ }, "time": "2026-03-13T05:47:23+00:00" }, + { + "name": "utopia-php/servers", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/servers.git", + "reference": "235be31200df9437fc96a1c270ffef4c64fafe52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/servers/zipball/235be31200df9437fc96a1c270ffef4c64fafe52", + "reference": "235be31200df9437fc96a1c270ffef4c64fafe52", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "utopia-php/di": "0.3.*", + "utopia-php/validators": "0.*" + }, + "require-dev": { + "laravel/pint": "^0.2.3", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Servers\\": "src/Servers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A base library for building Utopia style servers.", + "keywords": [ + "framework", + "php", + "servers", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/servers/issues", + "source": "https://github.com/utopia-php/servers/tree/0.3.0" + }, + "time": "2026-03-13T11:31:42+00:00" + }, { "name": "utopia-php/validators", "version": "0.2.0", @@ -3587,5 +3687,5 @@ "ext-swoole": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Http/Adapter/FPM/Request.php b/src/Http/Adapter/FPM/Request.php index d095445f..5c486c48 100644 --- a/src/Http/Adapter/FPM/Request.php +++ b/src/Http/Adapter/FPM/Request.php @@ -68,9 +68,26 @@ public function setServer(string $key, string $value): static */ public function getIP(): string { - $ips = explode(',', $this->getHeader('HTTP_X_FORWARDED_FOR', $this->getServer('REMOTE_ADDR') ?? '0.0.0.0')); + $remoteAddr = $this->getServer('REMOTE_ADDR') ?? '0.0.0.0'; - return trim($ips[0] ?? ''); + foreach ($this->trustedIpHeaders as $header) { + $headerValue = $this->getHeader($header); + + if (empty($headerValue)) { + continue; + } + + // Leftmost IP address is the address of the originating client + $ips = \explode(',', $headerValue); + $ip = \trim($ips[0]); + + // Validate IP format (supports both IPv4 and IPv6) + if (\filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + + return $remoteAddr; } /** diff --git a/src/Http/Adapter/Swoole/Request.php b/src/Http/Adapter/Swoole/Request.php index d5bd799c..41ac541d 100644 --- a/src/Http/Adapter/Swoole/Request.php +++ b/src/Http/Adapter/Swoole/Request.php @@ -73,9 +73,26 @@ public function setServer(string $key, string $value): static */ public function getIP(): string { - $ips = explode(',', $this->getHeader('x-forwarded-for', $this->getServer('remote_addr') ?? '0.0.0.0')); + $remoteAddr = $this->getServer('remote_addr') ?? '0.0.0.0'; - return trim($ips[0] ?? ''); + foreach ($this->trustedIpHeaders as $header) { + $headerValue = $this->getHeader($header); + + if (empty($headerValue)) { + continue; + } + + // Leftmost IP address is the address of the originating client + $ips = explode(',', $headerValue); + $ip = trim($ips[0]); + + // Validate IP format (supports both IPv4 and IPv6) + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + + return $remoteAddr; } /** @@ -259,9 +276,12 @@ public function getCookie(string $key, string $default = ''): string $cookies = \explode(';', $this->getHeader('cookie', '')); foreach ($cookies as $cookie) { $cookie = \trim($cookie); - [$cookieKey, $cookieValue] = \explode('=', $cookie, 2); - $cookieKey = \trim($cookieKey); - $cookieValue = \trim($cookieValue); + if ($cookie === '') { + continue; + } + $parts = \explode('=', $cookie, 2); + $cookieKey = \trim($parts[0]); + $cookieValue = isset($parts[1]) ? \trim($parts[1]) : ''; if ($cookieKey === $key) { return $cookieValue; } diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php index be48a027..3eb20417 100644 --- a/src/Http/Adapter/Swoole/Response.php +++ b/src/Http/Adapter/Swoole/Response.php @@ -86,14 +86,14 @@ public function sendHeader(string $key, mixed $value): void protected function sendCookie(string $name, string $value, array $options): void { $this->swoole->cookie( - name: $name, - value: $value, - expires: $options['expire'] ?? 0, - path: $options['path'] ?? '', - domain: $options['domain'] ?? '', - secure: $options['secure'] ?? false, - httponly: $options['httponly'] ?? false, - samesite: $options['samesite'] ?? false, + $name, + $value, + $options['expire'] ?? 0, + $options['path'] ?? '', + $options['domain'] ?? '', + $options['secure'] ?? false, + $options['httponly'] ?? false, + $options['samesite'] ?? false, ); } } diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php index 57b4f5fb..8ba2adbe 100755 --- a/src/Http/Adapter/Swoole/Server.php +++ b/src/Http/Adapter/Swoole/Server.php @@ -5,61 +5,61 @@ use Swoole\Coroutine; use Utopia\Http\Adapter; use Utopia\DI\Container; -use Swoole\Coroutine\Http\Server as SwooleServer; +use Swoole\Http\Server as SwooleServer; use Swoole\Http\Request as SwooleRequest; use Swoole\Http\Response as SwooleResponse; -use function Swoole\Coroutine\run; - class Server extends Adapter { protected SwooleServer $server; protected const REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container'; protected Container $container; - public function __construct(string $host, ?string $port = null, array $settings = [], ?Container $container = null) + public function __construct(string $host, ?string $port = null, array $settings = [], int $mode = SWOOLE_PROCESS, ?Container $container = null) { - $this->server = new SwooleServer($host, $port); - $this->server->set(\array_merge($settings, [ - 'enable_coroutine' => true, - 'http_parse_cookie' => false, - ])); + $this->server = new SwooleServer($host, (int) $port, $mode); + $this->server->set($settings); $this->container = $container ?? new Container(); } public function onRequest(callable $callback) { - $this->server->handle('/', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { + $this->server->on('request', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { $requestContainer = new Container($this->container); $requestContainer->set('swooleRequest', fn () => $request); $requestContainer->set('swooleResponse', fn () => $response); Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer; - $utopiaRequest = new Request($request); - $utopiaResponse = new Response($response); - - \call_user_func($callback, $utopiaRequest, $utopiaResponse); + \call_user_func($callback, new Request($request), new Response($response)); }); } public function getContainer(): Container { - return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container; + if (Coroutine::getCid() !== -1) { + return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container; + } + + return $this->container; } - public function onStart(callable $callback) + public function getServer(): SwooleServer { + return $this->server; + } - \call_user_func($callback, $this); + public function onStart(callable $callback) + { + $this->server->on('start', function () use ($callback) { + go(function () use ($callback) { + \call_user_func($callback, $this); + }); + }); } public function start() { - if (Coroutine::getCid() === -1) { - run(fn () => $this->server->start()); - } else { - $this->server->start(); - } + return $this->server->start(); } } diff --git a/src/Http/Adapter/SwooleCoroutine/Request.php b/src/Http/Adapter/SwooleCoroutine/Request.php new file mode 100644 index 00000000..a2aa49a5 --- /dev/null +++ b/src/Http/Adapter/SwooleCoroutine/Request.php @@ -0,0 +1,9 @@ +server = new SwooleServer($host, $port, false, true); + $this->server->set($settings); + $this->container = $container ?? new Container(); + } + + public function onRequest(callable $callback) + { + $this->server->handle('/', function (SwooleRequest $request, SwooleResponse $response) use ($callback) { + go(function () use ($request, $response, $callback) { + $requestContainer = new Container($this->container); + $requestContainer->set('swooleRequest', fn () => $request); + $requestContainer->set('swooleResponse', fn () => $response); + + Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] = $requestContainer; + + \call_user_func($callback, new Request($request), new Response($response)); + }); + }); + } + + public function getContainer(): Container + { + if (Coroutine::getCid() !== -1) { + return Coroutine::getContext()[self::REQUEST_CONTAINER_CONTEXT_KEY] ?? $this->container; + } + + return $this->container; + } + + public function getServer(): SwooleServer + { + return $this->server; + } + + public function onStart(callable $callback) + { + \call_user_func($callback, $this); + } + + public function start() + { + go(function () { + $this->server->start(); + }); + } +} diff --git a/src/Http/Hook.php b/src/Http/Hook.php deleted file mode 100644 index 0f177dc3..00000000 --- a/src/Http/Hook.php +++ /dev/null @@ -1,282 +0,0 @@ -action = function (): void { - }; - } - - /** - * Add Description - * - * @param string $desc - * @return static - */ - public function desc(string $desc): static - { - $this->desc = $desc; - - return $this; - } - - /** - * Get Description - * - * @return string - */ - public function getDesc(): string - { - return $this->desc; - } - - /** - * Add Group - * - * @param array $groups - * @return static - */ - public function groups(array $groups): static - { - $this->groups = $groups; - - return $this; - } - - /** - * Get Groups - * - * @return array - */ - public function getGroups(): array - { - return $this->groups; - } - - /** - * Add Label - * - * @param string $key - * @param mixed $value - * @return $this - */ - public function label(string $key, mixed $value): static - { - $this->labels[$key] = $value; - - return $this; - } - - /** - * Get Label - * - * Return given label value or default value if label doesn't exists - * - * @param string $key - * @param mixed $default - * @return mixed - */ - public function getLabel(string $key, mixed $default): mixed - { - return (isset($this->labels[$key])) ? $this->labels[$key] : $default; - } - - /** - * Add Action - * - * @param callable $action - * @return static - */ - public function action(callable $action): static - { - $this->action = $action; - - return $this; - } - - /** - * Get Action - * - * @return callable - */ - public function getAction() - { - return $this->action; - } - - /** - * Get Injections - * - * @return array - */ - public function getInjections(): array - { - return $this->injections; - } - - /** - * Inject - * - * @param string $injection - * @return static - * - * @throws Exception - */ - public function inject(string $injection): static - { - if (array_key_exists($injection, $this->injections)) { - throw new Exception('Injection already declared for '.$injection); - } - - $this->injections[$injection] = [ - 'name' => $injection, - 'order' => count($this->params) + count($this->injections), - ]; - - return $this; - } - - /** - * Add Param - * - * @param string $key - * @param mixed $default - * @param Validator|callable $validator - * @param string $description - * @param bool $optional - * @param array $injections - * @param bool $skipValidation - * @return static - */ - public function param(string $key, mixed $default, Validator|callable $validator, string $description = '', bool $optional = false, array $injections = [], bool $skipValidation = false): static - { - $this->params[$key] = [ - 'default' => $default, - 'validator' => $validator, - 'description' => $description, - 'optional' => $optional, - 'injections' => $injections, - 'skipValidation' => $skipValidation, - 'value' => null, - 'order' => count($this->params) + count($this->injections), - ]; - - return $this; - } - - /** - * Get Params - * - * @return array - */ - public function getParams(): array - { - return $this->params; - } - - /** - * Get Param Values - * - * @return array - */ - public function getParamsValues(): array - { - $values = []; - - foreach ($this->params as $key => $param) { - $values[$key] = $param['value']; - } - - return $values; - } - - /** - * Set Param Value - * - * @param string $key - * @param mixed $value - * @return static - * - * @throws Exception - */ - public function setParamValue(string $key, mixed $value): static - { - if (!isset($this->params[$key])) { - throw new Exception('Unknown key'); - } - - $this->params[$key]['value'] = $value; - - return $this; - } - - /** - * Get Param Value - * - * @param string $key - * @return mixed - * - * @throws Exception - */ - public function getParamValue(string $key): mixed - { - if (!isset($this->params[$key])) { - throw new Exception('Unknown key'); - } - - return $this->params[$key]['value']; - } -} diff --git a/src/Http/Http.php b/src/Http/Http.php index d3556a38..350fd45c 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -2,11 +2,16 @@ namespace Utopia\Http; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Utopia\DI\Container; +use Utopia\Servers\Hook; use Utopia\Validator; class Http { + public const COMPRESSION_MIN_SIZE_DEFAULT = 1024; + /** * Request method constants */ @@ -116,6 +121,15 @@ class Http */ protected static ?Route $wildcardRoute = null; + /** + * Compression + */ + protected bool $compression = false; + + protected int $compressionMinSize = Http::COMPRESSION_MIN_SIZE_DEFAULT; + + protected mixed $compressionSupported = []; + /** * @var Adapter */ @@ -132,6 +146,31 @@ public function __construct(Adapter $server, string $timezone) \date_default_timezone_set($timezone); $this->files = new Files(); $this->server = $server; + $this->container = $server->getContainer(); + } + + /** + * Set Compression + */ + public function setCompression(bool $compression) + { + $this->compression = $compression; + } + + /** + * Set minimum compression size + */ + public function setCompressionMinSize(int $compressionMinSize) + { + $this->compressionMinSize = $compressionMinSize; + } + + /** + * Set supported compression algorithms + */ + public function setCompressionSupported(mixed $compressionSupported) + { + $this->compressionSupported = $compressionSupported; } /** @@ -352,7 +391,7 @@ public function getResource(string $name): mixed { try { return $this->server->getContainer()->get($name); - } catch (\Throwable $e) { + } catch (ContainerExceptionInterface | NotFoundExceptionInterface $e) { // Normalize DI container errors to the Http layer's "resource" terminology. $message = \str_replace('dependency', 'resource', $e->getMessage()); @@ -389,6 +428,16 @@ public function getResources(array $list): array * @param string[] $injections */ public function setResource(string $name, callable $callback, array $injections = []): void + { + $this->container->set($name, $callback, $injections); + } + + /** + * Set a request-scoped resource on the current request's container. + * + * @param string[] $injections + */ + protected function setRequestResource(string $name, callable $callback, array $injections = []): void { $this->server->getContainer()->set($name, $callback, $injections); } @@ -610,7 +659,9 @@ public function execute(Route $route, Request $request): static { $arguments = []; $groups = $route->getGroups(); - $pathValues = $route->getPathValues($request); + + $preparedPath = Router::preparePath($route->getMatchedPath()); + $pathValues = $route->getPathValues($request, $preparedPath[0]); try { if ($route->getHook()) { @@ -652,7 +703,7 @@ public function execute(Route $route, Request $request): static } } } catch (\Throwable $e) { - $this->setResource('error', fn () => $e, []); + $this->setRequestResource('error', fn () => $e, []); foreach ($groups as $group) { foreach (self::$errors as $error) { // Group error hooks @@ -702,7 +753,7 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) $arg = $existsInRequest ? $requestParams[$key] : $param['default']; if (\is_callable($arg) && !\is_string($arg)) { - $arg = \call_user_func_array($arg, $this->getResources($param['injections'])); + $arg = \call_user_func_array($arg, \array_values($this->getResources($param['injections']))); } $value = $existsInValues ? $values[$key] : $arg; @@ -738,8 +789,14 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) */ public function run(Request $request, Response $response): static { - $this->setResource('request', fn () => $request); - $this->setResource('response', fn () => $response); + if ($this->compression) { + $response->setAcceptEncoding($request->getHeader('accept-encoding', '')); + $response->setCompressionMinSize($this->compressionMinSize); + $response->setCompressionSupported($this->compressionSupported); + } + + $this->setRequestResource('request', fn () => $request); + $this->setRequestResource('response', fn () => $response); try { foreach (self::$requestHooks as $hook) { @@ -747,7 +804,7 @@ public function run(Request $request, Response $response): static \call_user_func_array($hook->getAction(), $arguments); } } catch (\Exception $e) { - $this->setResource('error', fn () => $e, []); + $this->setRequestResource('error', fn () => $e, []); foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { @@ -777,7 +834,7 @@ public function run(Request $request, Response $response): static $route = $this->match($request); $groups = ($route instanceof Route) ? $route->getGroups() : []; - $this->setResource('route', fn () => $route, []); + $this->setRequestResource('route', fn () => $route, []); if (self::REQUEST_METHOD_HEAD == $method) { $method = self::REQUEST_METHOD_GET; @@ -805,7 +862,7 @@ public function run(Request $request, Response $response): static foreach (self::$errors as $error) { // Global error hooks /** @var Hook $error */ if (\in_array('*', $error->getGroups())) { - $this->setResource('error', function () use ($e) { + $this->setRequestResource('error', function () use ($e) { return $e; }, []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); @@ -822,7 +879,7 @@ public function run(Request $request, Response $response): static $path = \parse_url($request->getURI(), PHP_URL_PATH); $route->path($path); - $this->setResource('route', fn () => $route, []); + $this->setRequestResource('route', fn () => $route, []); } if (null !== $route) { @@ -845,7 +902,7 @@ public function run(Request $request, Response $response): static } catch (\Throwable $e) { foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { - $this->setResource('error', function () use ($e) { + $this->setRequestResource('error', function () use ($e) { return $e; }, []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); @@ -855,7 +912,7 @@ public function run(Request $request, Response $response): static } else { foreach (self::$errors as $error) { // Global error hooks if (\in_array('*', $error->getGroups())) { - $this->setResource('error', fn () => new Exception('Not Found', 404), []); + $this->setRequestResource('error', fn () => new Exception('Not Found', 404), []); \call_user_func_array($error->getAction(), $this->getArguments($error, [], $request->getParams())); } } @@ -886,7 +943,7 @@ protected function validate(string $key, array $param, mixed $value): void $validator = $param['validator']; // checking whether the class exists if (\is_callable($validator)) { - $validator = \call_user_func_array($validator, $this->getResources($param['injections'])); + $validator = \call_user_func_array($validator, \array_values($this->getResources($param['injections']))); } if (!$validator instanceof Validator) { // is the validator object an instance of the Validator class diff --git a/src/Http/Request.php b/src/Http/Request.php index c9762123..1bc9db0b 100755 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -46,6 +46,8 @@ abstract class Request */ protected $headers = null; + protected array $trustedIpHeaders = []; + /** * Get Param * @@ -137,6 +139,24 @@ abstract public function getServer(string $key, ?string $default = null): ?strin */ abstract public function setServer(string $key, string $value): static; + /** + * Set Trusted IP Headers + * + * Set which headers to trust for determining client IP address. + * Headers are checked in order; the first one found with a valid IP is used. + * + * @param array $headers + * @return static + */ + public function setTrustedIpHeaders(array $headers): static + { + $normalized = \array_map('strtolower', $headers); + $trimmed = \array_map('trim', $normalized); + $this->trustedIpHeaders = \array_filter($trimmed); + + return $this; + } + /** * Get IP * diff --git a/src/Http/Response.php b/src/Http/Response.php index 444bc4f8..9140056e 100755 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -2,6 +2,8 @@ namespace Utopia\Http; +use Utopia\Compression\Compression; + abstract class Response { /** @@ -183,17 +185,71 @@ abstract class Response * * @var array */ - protected $compressed = [ + private static $compressible = [ + // Text + 'text/html' => true, + 'text/richtext' => true, 'text/plain' => true, 'text/css' => true, - 'text/javascript' => true, + 'text/x-script' => true, + 'text/x-component' => true, + 'text/x-java-source' => true, + 'text/x-markdown' => true, + + // JavaScript 'application/javascript' => true, - 'text/html' => true, - 'text/html; charset=UTF-8' => true, + 'application/x-javascript' => true, + 'text/javascript' => true, + 'text/js' => true, + + // Icons + 'image/x-icon' => true, + 'image/vnd.microsoft.icon' => true, + + // Scripts + 'application/x-perl' => true, + 'application/x-httpd-cgi' => true, + + // XML and JSON + 'text/xml' => true, + 'application/xml' => true, + 'application/rss+xml' => true, + 'application/vnd.api+json' => true, + 'application/x-protobuf' => true, 'application/json' => true, - 'application/json; charset=UTF-8' => true, + 'application/manifest+json' => true, + 'application/ld+json' => true, + 'application/graphql+json' => true, + 'application/geo+json' => true, + + // Multipart + 'multipart/bag' => true, + 'multipart/mixed' => true, + + // XHTML + 'application/xhtml+xml' => true, + + // Fonts + 'font/ttf' => true, + 'font/otf' => true, + 'font/x-woff' => true, 'image/svg+xml' => true, - 'application/xml+rss' => true, + 'application/vnd.ms-fontobject' => true, + 'application/ttf' => true, + 'application/x-ttf' => true, + 'application/otf' => true, + 'application/x-otf' => true, + 'application/truetype' => true, + 'application/opentype' => true, + 'application/x-opentype' => true, + 'application/font-woff' => true, + 'application/eot' => true, + 'application/font' => true, + 'application/font-sfnt' => true, + + // WebAssembly + 'application/wasm' => true, + 'application/javascript-binast' => true, ]; public const COOKIE_SAMESITE_NONE = 'None'; @@ -246,6 +302,21 @@ abstract class Response */ protected int $size = 0; + /** + * @var string + */ + protected string $acceptEncoding = ''; + + /** + * @var int + */ + protected int $compressionMinSize = Http::COMPRESSION_MIN_SIZE_DEFAULT; + + /** + * @var mixed + */ + protected mixed $compressionSupported = []; + /** * Response constructor. * @@ -256,6 +327,58 @@ public function __construct(float $time = 0) $this->startTime = (!empty($time)) ? $time : \microtime(true); } + private function isCompressible(?string $contentType): bool + { + if (!$contentType) { + return false; + } + + // Strip any parameters (e.g. ;charset=utf-8) + $contentType = strtolower(trim(explode(';', $contentType)[0])); + + return isset(self::$compressible[$contentType]); + } + + /** + * Set accept encoding + * + * Set HTTP accept encoding header. + * + * @param string $acceptEncoding + */ + public function setAcceptEncoding(string $acceptEncoding): static + { + $this->acceptEncoding = $acceptEncoding; + + return $this; + } + + /** + * Set min compression size + * + * Set minimum size for compression to be applied in bytes. + * + * @param int $compressionMinSize + */ + public function setCompressionMinSize(int $compressionMinSize): static + { + $this->compressionMinSize = $compressionMinSize; + + return $this; + } + + /** + * Set supported compression algorithms + * + * @param mixed $compressionSupported + */ + public function setCompressionSupported(mixed $compressionSupported): static + { + $this->compressionSupported = $compressionSupported; + + return $this; + } + /** * Set content type * @@ -482,50 +605,74 @@ public function send(string $body = ''): void return; } - $this->sent = true; + $this->appendCookies(); - $this->addHeader('X-Debug-Speed', (string) (\microtime(true) - $this->startTime)); + $hasContentEncoding = false; + foreach ($this->headers as $name => $values) { + if (\strtolower($name) === 'content-encoding') { + $hasContentEncoding = true; + break; + } + } - $this - ->appendCookies() - ->appendHeaders(); + // Compress body only if all conditions are met: + if ( + !$hasContentEncoding && + !empty($this->acceptEncoding) && + $this->isCompressible($this->contentType) && + strlen($body) > $this->compressionMinSize + ) { + $algorithm = Compression::fromAcceptEncoding($this->acceptEncoding, $this->compressionSupported); + + if ($algorithm) { + $body = $algorithm->compress($body); + $this->addHeader('Content-Length', (string) \strlen($body)); + $this->addHeader('Content-Encoding', $algorithm->getContentEncoding()); + $this->addHeader('X-Utopia-Compression', 'true'); + $this->addHeader('Vary', 'Accept-Encoding'); + } + } - $this->headersSent = true; + $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime)); + $this->appendHeaders(); - if (!$this->disablePayload) { - $length = strlen($body); - - $headersSize = 0; - foreach ($this->headers as $name => $values) { - if (\is_array($values)) { - foreach ($values as $value) { - $headersSize += \strlen($name . ': ' . $value); - } - $headersSize += (\count($values) - 1) * 2; // linebreaks - } else { - $headersSize += \strlen($name . ': ' . $values); + // Send response + if ($this->disablePayload) { + $this->end(); + $this->sent = true; + + return; + } + + $headersSize = 0; + foreach ($this->headers as $name => $values) { + if (\is_array($values)) { + foreach ($values as $value) { + $headersSize += \strlen($name . ': ' . $value); } - } - $headersSize += (\count($this->headers) - 1) * 2; // linebreaks - $this->size = $this->size + $headersSize + $length; - - if (array_key_exists( - $this->contentType, - $this->compressed - ) && ($length <= self::CHUNK_SIZE)) { // Dont compress with GZIP / Brotli if header is not listed and size is bigger than 2mb - $this->end($body); + $headersSize += (\count($values) - 1) * 2; // linebreaks } else { - for ($i = 0; $i < ceil($length / self::CHUNK_SIZE); $i++) { - $this->write(substr($body, ($i * self::CHUNK_SIZE), min(self::CHUNK_SIZE, $length - ($i * self::CHUNK_SIZE)))); - } - - $this->end(); + $headersSize += \strlen($name . ': ' . $values); } + } + $headersSize += (\count($this->headers) - 1) * 2; // linebreaks + + $bodyLength = strlen($body); + $this->size += $headersSize + $bodyLength; - $this->disablePayload(); + if ($bodyLength <= self::CHUNK_SIZE) { + $this->end($body); } else { + $chunks = str_split($body, self::CHUNK_SIZE); + foreach ($chunks as $chunk) { + $this->write($chunk); + } $this->end(); } + + $this->sent = true; + + $this->disablePayload(); } /** diff --git a/src/Http/Route.php b/src/Http/Route.php index 8a7d0620..1ce2f111 100755 --- a/src/Http/Route.php +++ b/src/Http/Route.php @@ -2,6 +2,8 @@ namespace Utopia\Http; +use Utopia\Servers\Hook; + class Route extends Hook { /** @@ -28,7 +30,7 @@ class Route extends Hook /** * Path params. * - * @var array + * @var array> */ protected array $pathParams = []; @@ -46,13 +48,25 @@ class Route extends Hook */ protected int $order; + protected string $matchedPath = ''; + public function __construct(string $method, string $path) { + parent::__construct(); $this->path($path); $this->method = $method; $this->order = ++self::$counter; - $this->action = function (): void { - }; + } + + public function setMatchedPath(string $path): self + { + $this->matchedPath = $path; + return $this; + } + + public function getMatchedPath(): string + { + return $this->matchedPath; } /** @@ -141,23 +155,30 @@ public function getHook(): bool * @param int $index * @return void */ - public function setPathParam(string $key, int $index): void + public function setPathParam(string $key, int $index, string $path = ''): void { - $this->pathParams[$key] = $index; + $this->pathParams[$path][$key] = $index; } /** * Get path params. * * @param \Utopia\Http\Request $request + * @param string $path * @return array */ - public function getPathValues(Request $request): array + public function getPathValues(Request $request, string $path = ''): array { $pathValues = []; $parts = explode('/', ltrim($request->getURI(), '/')); - foreach ($this->pathParams as $key => $index) { + if (empty($path)) { + $pathParams = $this->pathParams[$path] ?? \array_values($this->pathParams)[0] ?? []; + } else { + $pathParams = $this->pathParams[$path] ?? []; + } + + foreach ($pathParams as $key => $index) { if (array_key_exists($index, $parts)) { $pathValues[$key] = $parts[$index]; } diff --git a/src/Http/Router.php b/src/Http/Router.php index d3324553..9bc2a969 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -86,7 +86,7 @@ public static function addRoute(Route $route): void } foreach ($params as $key => $index) { - $route->setPathParam($key, $index); + $route->setPathParam($key, $index, $path); } self::$routes[$route->getMethod()][$path] = $route; @@ -101,12 +101,16 @@ public static function addRoute(Route $route): void */ public static function addRouteAlias(string $path, Route $route): void { - [$alias] = self::preparePath($path); + [$alias, $params] = self::preparePath($path); if (array_key_exists($alias, self::$routes[$route->getMethod()]) && !self::$allowOverride) { throw new Exception("Route for ({$route->getMethod()}:{$alias}) already registered."); } + foreach ($params as $key => $index) { + $route->setPathParam($key, $index, $alias); + } + self::$routes[$route->getMethod()][$alias] = $route; } @@ -123,7 +127,7 @@ public static function match(string $method, string $path): Route|null return null; } - $parts = array_values(array_filter(explode('/', $path))); + $parts = array_values(array_filter(explode('/', $path), fn ($segment) => $segment !== '')); $length = count($parts) - 1; $filteredParams = array_filter(self::$params, fn ($i) => $i <= $length); @@ -138,7 +142,9 @@ public static function match(string $method, string $path): Route|null ); if (array_key_exists($match, self::$routes[$method])) { - return self::$routes[$method][$match]; + $route = self::$routes[$method][$match]; + $route->setMatchedPath($match); + return $route; } } @@ -147,7 +153,9 @@ public static function match(string $method, string $path): Route|null */ $match = self::WILDCARD_TOKEN; if (array_key_exists($match, self::$routes[$method])) { - return self::$routes[$method][$match]; + $route = self::$routes[$method][$match]; + $route->setMatchedPath($match); + return $route; } /** @@ -157,7 +165,9 @@ public static function match(string $method, string $path): Route|null $current = ($current ?? '') . "{$part}/"; $match = $current . self::WILDCARD_TOKEN; if (array_key_exists($match, self::$routes[$method])) { - return self::$routes[$method][$match]; + $route = self::$routes[$method][$match]; + $route->setMatchedPath($match); + return $route; } } @@ -192,7 +202,7 @@ protected static function combinations(array $set): iterable * @param string $path * @return array */ - protected static function preparePath(string $path): array + public static function preparePath(string $path): array { $parts = array_values(array_filter(explode('/', $path))); $prepare = ''; diff --git a/src/Http/View.php b/src/Http/View.php new file mode 100644 index 00000000..d4c7008e --- /dev/null +++ b/src/Http/View.php @@ -0,0 +1,342 @@ +setPath($path) method + * + * @param string $path + * + * @throws Exception + */ + public function __construct(string $path = '') + { + $this->setPath($path); + + $this + ->addFilter(self::FILTER_ESCAPE, function (string $value) { + return \htmlentities($value, ENT_QUOTES, 'UTF-8'); + }) + ->addFilter(self::FILTER_NL2P, function (string $value) { + $paragraphs = ''; + + foreach (\explode("\n\n", $value) as $line) { + if (\trim($line)) { + $paragraphs .= '

'.$line.'

'; + } + } + + $paragraphs = \str_replace("\n", '
', $paragraphs); + + return $paragraphs; + }); + } + + /** + * Set param + * + * Assign a parameter by key + * + * @param string $key + * @param mixed $value + * + * @throws Exception + */ + public function setParam(string $key, mixed $value, bool $escapeHtml = true): static + { + if (\strpos($key, '.') !== false) { + throw new Exception('$key can\'t contain a dot "." character'); + } + + if (is_string($value) && $escapeHtml) { + $value = \htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + + $this->params[$key] = $value; + + return $this; + } + + /** + * Set parent View object conatining this object + * + * @param self $view + */ + public function setParent(self $view): static + { + $this->parent = $view; + + return $this; + } + + /** + * Return a View instance of the parent view containing this view + * + * @return self|null + */ + public function getParent(): ?self + { + if (!empty($this->parent)) { + return $this->parent; + } + + return null; + } + + /** + * Get param + * + * Returns an assigned parameter by its key or $default if param key doesn't exists + * + * @param string $path + * @param mixed $default (optional) + * @return mixed + */ + public function getParam(string $path, mixed $default = null): mixed + { + $path = \explode('.', $path); + $temp = $this->params; + + foreach ($path as $key) { + $temp = (isset($temp[$key])) ? $temp[$key] : null; + + if (null !== $temp) { + $value = $temp; + } else { + return $default; + } + } + + return $value; + } + + /** + * Set path + * + * Set object template path that will be used to render view output + * + * @param string $path + * + * @throws Exception + */ + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + /** + * Set rendered + * + * By enabling rendered state to true, the object will not render its template and will return an empty string instead + * + * @param bool $state + */ + public function setRendered(bool $state = true): static + { + $this->rendered = $state; + + return $this; + } + + /** + * Is rendered + * + * Return whether current View rendering state is set to true or false + * + * @return bool + */ + public function isRendered(): bool + { + return $this->rendered; + } + + /** + * Add Filter + * + * @param string $name + * @param callable $callback + */ + public function addFilter(string $name, callable $callback): static + { + $this->filters[$name] = $callback; + + return $this; + } + + /** + * Output and filter value + * + * @param mixed $value + * @param string|array $filter + * @return mixed + * + * @throws Exception + */ + public function print(mixed $value, string|array $filter = ''): mixed + { + if (!empty($filter)) { + if (\is_array($filter)) { + foreach ($filter as $callback) { + if (!isset($this->filters[$callback])) { + throw new Exception('Filter "'.$callback.'" is not registered'); + } + + $value = $this->filters[$callback]($value); + } + } else { + if (!isset($this->filters[$filter])) { + throw new Exception('Filter "'.$filter.'" is not registered'); + } + + $value = $this->filters[$filter]($value); + } + } + + return $value; + } + + /** + * Render + * + * Render view .phtml template file if template has not been set as rendered yet using $this->setRendered(true). + * In case path is not readable throws Exception. + * + * @param bool $minify + * @return string + * + * @throws Exception + */ + public function render(bool $minify = true): string + { + if ($this->rendered) { // Don't render any template + return ''; + } + + \ob_start(); //Start of build + + if (\is_readable($this->path)) { + /** + * Include template file + * + * @psalm-suppress UnresolvableInclude + */ + include $this->path; + } else { + \ob_end_clean(); + throw new Exception('"'.$this->path.'" view template is not readable'); + } + + $html = \ob_get_contents(); + + \ob_end_clean(); //End of build + + if ($minify) { + // Searching textarea and pre + \preg_match_all('#\.*\<\/textarea\>#Uis', $html, $foundTxt); + \preg_match_all('#\.*\<\/pre\>#Uis', $html, $foundPre); + + // replacing both with /
$index
+ $html = \str_replace($foundTxt[0], \array_map(function ($el) { + return ''; + }, \array_keys($foundTxt[0])), $html); + $html = \str_replace($foundPre[0], \array_map(function ($el) { + return '
'.$el.'
'; + }, \array_keys($foundPre[0])), $html); + + // your stuff + $search = [ + '/\>[^\S ]+/s', // strip whitespaces after tags, except space + '/[^\S ]+\', + '<', + '\\1', + ]; + + $html = \preg_replace($search, $replace, $html); + + // Replacing back with content + $html = \str_replace(\array_map(function ($el) { + return ''; + }, \array_keys($foundTxt[0])), $foundTxt[0], $html); + $html = \str_replace(\array_map(function ($el) { + return '
'.$el.'
'; + }, \array_keys($foundPre[0])), $foundPre[0], $html); + } + + return $html; + } + + /* View Helpers */ + + /** + * Exec + * + * Exec child View components + * + * @param array|self $view + * @return string + * + * @throws Exception + */ + public function exec($view): string + { + $output = ''; + + if (\is_array($view)) { + foreach ($view as $node) { /* @var $node self */ + if ($node instanceof self) { + $node->setParent($this); + $output .= $node->render(); + } + } + } + + if ($view instanceof self) { + $view->setParent($this); + $output = $view->render(); + } + + return $output; + } +} diff --git a/tests/HookTest.php b/tests/HookTest.php deleted file mode 100644 index 49a0afd5..00000000 --- a/tests/HookTest.php +++ /dev/null @@ -1,101 +0,0 @@ -hook = new Hook(); - } - - public function testDescriptionCanBeSet() - { - $this->assertEquals('', $this->hook->getDesc()); - - $this->hook->desc('new hook'); - - $this->assertEquals('new hook', $this->hook->getDesc()); - } - - public function testGroupsCanBeSet() - { - $this->assertEquals([], $this->hook->getGroups()); - - $this->hook->groups(['api', 'homepage']); - - $this->assertEquals(['api', 'homepage'], $this->hook->getGroups()); - } - - public function testActionCanBeSet() - { - $this->assertEquals(function () { - }, $this->hook->getAction()); - - $this->hook->action(fn () => 'hello world'); - - $this->assertEquals('hello world', $this->hook->getAction()()); - } - - public function testParamCanBeSet() - { - $this->assertEquals([], $this->hook->getParams()); - - $this->hook - ->param('x', '', new Text(10)) - ->param('y', '', new Text(10)); - - $this->assertCount(2, $this->hook->getParams()); - } - - public function testResourcesCanBeInjected() - { - $this->assertEquals([], $this->hook->getInjections()); - - $this->hook - ->inject('user') - ->inject('time') - ->action(function () { - }); - - $this->assertCount(2, $this->hook->getInjections()); - $this->assertEquals('user', $this->hook->getInjections()['user']['name']); - $this->assertEquals('time', $this->hook->getInjections()['time']['name']); - } - - public function testParamValuesCanBeSet() - { - $this->assertEquals([], $this->hook->getParams()); - - $values = [ - 'x' => 'hello', - 'y' => 'world', - ]; - - $this->hook - ->param('x', '', new Numeric()) - ->param('y', '', new Numeric()); - - foreach ($this->hook->getParams() as $key => $param) { - $this->hook->setParamValue($key, $values[$key]); - } - - $this->assertCount(2, $this->hook->getParams()); - $this->assertEquals('hello', $this->hook->getParams()['x']['value']); - $this->assertEquals('world', $this->hook->getParams()['y']['value']); - } - - public function tearDown(): void - { - $this->hook = null; - } -}