From 6ab95a7541baa36594c3d9194ca9d02d9d4f5ff7 Mon Sep 17 00:00:00 2001 From: abdulmohsen Date: Wed, 6 Mar 2024 00:03:13 +0300 Subject: [PATCH] Removed dependency on laminas/laminas-httphandlerrunner --- composer.json | 1 - composer.lock | 124 ++++++----------------- src/API/History/View.php | 4 +- src/Libs/Emitter.php | 107 +++++++++++++++++++ src/Libs/Exceptions/EmitterException.php | 23 +++++ src/Libs/Initializer.php | 53 ++++++---- src/Libs/helpers.php | 8 +- 7 files changed, 203 insertions(+), 117 deletions(-) create mode 100644 src/Libs/Emitter.php create mode 100644 src/Libs/Exceptions/EmitterException.php diff --git a/composer.json b/composer.json index cc20fee5..1107cb88 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,6 @@ "psr/http-server-middleware": "^1.0.1", "nyholm/psr7": "^1.5.1", "nyholm/psr7-server": "^1.0.2", - "laminas/laminas-httphandlerrunner": "^2.1", "dragonmantank/cron-expression": "^3.3.2", "halaxa/json-machine": "^1.1.1", "league/route": "^5.1.2", diff --git a/composer.lock b/composer.lock index f93c9d3a..87bfe482 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": "3f2df783b867f04c981b9f16ddb2e8bc", + "content-hash": "2d5ce0bc275cb65058002af7ef80f8f1", "packages": [ { "name": "dragonmantank/cron-expression", @@ -126,73 +126,6 @@ ], "time": "2023-11-28T21:12:40+00:00" }, - { - "name": "laminas/laminas-httphandlerrunner", - "version": "2.10.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-httphandlerrunner.git", - "reference": "35a0ba92e940a2f9533754f5a56187fa321f7693" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/35a0ba92e940a2f9533754f5a56187fa321f7693", - "reference": "35a0ba92e940a2f9533754f5a56187fa321f7693", - "shasum": "" - }, - "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "psr/http-message": "^1.0 || ^2.0", - "psr/http-message-implementation": "^1.0 || ^2.0", - "psr/http-server-handler": "^1.0" - }, - "require-dev": { - "laminas/laminas-coding-standard": "~2.5.0", - "laminas/laminas-diactoros": "^3.3.0", - "phpunit/phpunit": "^10.5.5", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.18" - }, - "type": "library", - "extra": { - "laminas": { - "config-provider": "Laminas\\HttpHandlerRunner\\ConfigProvider" - } - }, - "autoload": { - "psr-4": { - "Laminas\\HttpHandlerRunner\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", - "homepage": "https://laminas.dev", - "keywords": [ - "components", - "laminas", - "mezzio", - "psr-15", - "psr-7" - ], - "support": { - "chat": "https://laminas.dev/chat", - "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/", - "forum": "https://discourse.laminas.dev", - "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues", - "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom", - "source": "https://github.com/laminas/laminas-httphandlerrunner" - }, - "funding": [ - { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" - } - ], - "time": "2024-01-04T10:50:34+00:00" - }, { "name": "league/container", "version": "4.2.0", @@ -1745,16 +1678,16 @@ }, { "name": "symfony/http-client", - "version": "v6.4.4", + "version": "v6.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "aa6281ddb3be1b3088f329307d05abfbbeb97649" + "reference": "f3c86a60a3615f466333a11fd42010d4382a82c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/aa6281ddb3be1b3088f329307d05abfbbeb97649", - "reference": "aa6281ddb3be1b3088f329307d05abfbbeb97649", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f3c86a60a3615f466333a11fd42010d4382a82c7", + "reference": "f3c86a60a3615f466333a11fd42010d4382a82c7", "shasum": "" }, "require": { @@ -1818,7 +1751,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.4" + "source": "https://github.com/symfony/http-client/tree/v6.4.5" }, "funding": [ { @@ -1834,7 +1767,7 @@ "type": "tidelift" } ], - "time": "2024-02-14T16:28:12+00:00" + "time": "2024-03-02T12:45:30+00:00" }, { "name": "symfony/http-client-contracts", @@ -2706,20 +2639,21 @@ }, { "name": "phar-io/manifest", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { "ext-dom": "*", + "ext-libxml": "*", "ext-phar": "*", "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", @@ -2760,9 +2694,15 @@ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.3" + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-07-20T11:28:43+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { "name": "phar-io/version", @@ -3243,12 +3183,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "8f1e484da92817191c75c9b00108f13fb62fd741" + "reference": "f55d0680d4af9e99a48d9a545863b670f6a0e53e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/8f1e484da92817191c75c9b00108f13fb62fd741", - "reference": "8f1e484da92817191c75c9b00108f13fb62fd741", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/f55d0680d4af9e99a48d9a545863b670f6a0e53e", + "reference": "f55d0680d4af9e99a48d9a545863b670f6a0e53e", "shasum": "" }, "conflict": { @@ -3642,7 +3582,7 @@ "phpmyfaq/phpmyfaq": "<3.2.5", "phpoffice/phpexcel": "<1.8", "phpoffice/phpspreadsheet": "<1.16", - "phpseclib/phpseclib": "<2.0.31|>=3,<3.0.34", + "phpseclib/phpseclib": "<2.0.47|>=3,<3.0.36", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<3.4.3", "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3", @@ -3760,7 +3700,7 @@ "studio-42/elfinder": "<2.1.62", "subhh/libconnect": "<7.0.8|>=8,<8.1", "sukohi/surpass": "<1", - "sulu/sulu": "<1.6.44|>=2,<2.4.16|>=2.5,<2.5.12", + "sulu/sulu": "<1.6.44|>=2,<2.4.17|>=2.5,<2.5.13", "sumocoders/framework-user-bundle": "<1.4", "superbig/craft-audit": "<3.0.2", "swag/paypal": "<5.4.4", @@ -3975,7 +3915,7 @@ "type": "tidelift" } ], - "time": "2024-03-01T21:04:56+00:00" + "time": "2024-03-05T16:04:58+00:00" }, { "name": "sebastian/cli-parser", @@ -4943,16 +4883,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", "shasum": "" }, "require": { @@ -4981,7 +4921,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.2" + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" }, "funding": [ { @@ -4989,7 +4929,7 @@ "type": "github" } ], - "time": "2023-11-20T00:12:19+00:00" + "time": "2024-03-03T12:36:25+00:00" } ], "aliases": [ diff --git a/src/API/History/View.php b/src/API/History/View.php index 54e973c2..0dc2cb9c 100644 --- a/src/API/History/View.php +++ b/src/API/History/View.php @@ -27,13 +27,13 @@ final readonly class View $entity = Container::get(iState::class)::fromArray([iState::COLUMN_ID => $id]); - if (null === ($item = $this->db->find($entity))) { + if (null === ($item = $this->db->get($entity))) { return api_error('Not found', HTTP_STATUS::HTTP_NOT_FOUND); } $apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme(''); - $item = array_pop($item)->getAll(); + $item = $item->getAll(); $item[iState::COLUMN_WATCHED] = $entity->isWatched(); $item[iState::COLUMN_UPDATED] = makeDate($entity->updated); diff --git a/src/Libs/Emitter.php b/src/Libs/Emitter.php new file mode 100644 index 00000000..de223bd0 --- /dev/null +++ b/src/Libs/Emitter.php @@ -0,0 +1,107 @@ +assertNoPreviousOutput(); + + $this->emitHeaders($response); + + // -- should be called after `emitHeaders()` in order to prevent PHP from changing the status code. + $this->emitStatusLine($response); + + if ($response->getBody()->getSize() > 0) { + echo $response->getBody(); + } + } + + /** + * Checks to see if content has previously been sent. + * + * If either headers have been sent or the output buffer contains content, + * raises an exception. + * + * @throws EmitterException If headers have already been sent. + * @throws EmitterException If output is present in the output buffer. + */ + private function assertNoPreviousOutput(): void + { + $filename = null; + $line = null; + + if (headers_sent($filename, $line)) { + assert(is_string($filename) && is_int($line)); + throw EmitterException::forHeadersSent($filename, $line); + } + + if (ob_get_level() > 0 && ob_get_length() > 0) { + throw EmitterException::forOutputSent(); + } + } + + /** + * Emit the status line. + * + * Emits the status line using the protocol version and status code from + * the response; if a reason phrase is available, it, too, is emitted. + * + * It is important to mention that this method should be called after + * `emitHeaders()` in order to prevent PHP from changing the status code of + * the emitted response. + */ + private function emitStatusLine(IResponse $response): void + { + $statusCode = $response->getStatusCode(); + $reasonPhrase = $response->getReasonPhrase(); + + $this->header(r('HTTP/{version} {status}{phrase}', [ + 'version' => $response->getProtocolVersion(), + 'status' => $statusCode, + 'phrase' => !empty($reasonPhrase) ? ' ' . $reasonPhrase : '' + ]), true, $statusCode); + } + + /** + * Emit response headers. + * + * Loops through each header, emitting each; if the header value + * is an array with multiple values, ensures that each is sent + * in such a way as to create aggregate headers (instead of replace + * the previous). + */ + private function emitHeaders(IResponse $response): void + { + $statusCode = $response->getStatusCode(); + + foreach ($response->getHeaders() as $header => $values) { + assert(is_string($header)); + $name = ucwords($header, '-'); + $first = $name !== 'Set-Cookie'; + foreach ($values as $value) { + $this->header(r('{header}: {value}', [ + 'header' => $name, + 'value' => $value, + ]), $first, $statusCode); + $first = false; + } + } + } + + private function header(string $headerName, bool $replace, int $statusCode): void + { + header($headerName, $replace, $statusCode); + } +} diff --git a/src/Libs/Exceptions/EmitterException.php b/src/Libs/Exceptions/EmitterException.php new file mode 100644 index 00000000..bceb9273 --- /dev/null +++ b/src/Libs/Exceptions/EmitterException.php @@ -0,0 +1,23 @@ + $filename, + 'line' => $line, + ])); + } + + public static function forOutputSent(): self + { + return new self('Output has been emitted previously. Cannot emit response.'); + } +} diff --git a/src/Libs/Initializer.php b/src/Libs/Initializer.php index 83e608cc..7440e5e1 100644 --- a/src/Libs/Initializer.php +++ b/src/Libs/Initializer.php @@ -15,8 +15,6 @@ use App\Libs\Extends\RouterStrategy; use Closure; use DateInterval; use ErrorException; -use Laminas\HttpHandlerRunner\Emitter\EmitterInterface as iEmitter; -use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use League\Route\Http\Exception as RouterHttpException; use League\Route\RouteGroup; use League\Route\Router as APIRouter; @@ -184,12 +182,12 @@ final class Initializer * Run the application in HTTP Context. * * @param iRequest|null $request If null, the request will be created from globals. - * @param iEmitter|null $emitter If null, the emitter will be created from globals. + * @param callable(ResponseInterface):void|null $emitter If null, the emitter will be created from globals. * @param null|Closure(iRequest): ResponseInterface $fn If null, the default HTTP server will be used. */ - public function http(iRequest|null $request = null, iEmitter|null $emitter = null, Closure|null $fn = null): void + public function http(iRequest|null $request = null, callable|null $emitter = null, Closure|null $fn = null): void { - $emitter = $emitter ?? new SapiEmitter(); + $emitter = $emitter ?? new Emitter(); if (null === $request) { $factory = new Psr17Factory(); @@ -222,7 +220,7 @@ final class Initializer ]); } - $emitter->emit($response); + $emitter($response); } /** @@ -240,10 +238,17 @@ final class Initializer $class = null; $request = $realRequest; + $requestPath = $request->getUri()->getPath(); + // -- health endpoint. - if (true === str_starts_with($request->getUri()->getPath(), '/healthcheck')) { - return new Response(200); + if (true === str_starts_with($requestPath, '/healthcheck')) { + return api_response([], HTTP_STATUS::HTTP_OK); + } + + // -- favicon endpoint. + if (true === str_starts_with($requestPath, '/favicon.ico')) { + return api_response([], HTTP_STATUS::HTTP_NO_CONTENT); } // -- Forward requests to API server. @@ -259,8 +264,12 @@ final class Initializer $apikey = ag($realRequest->getQueryParams(), 'apikey', $realRequest->getHeaderLine('x-apikey')); if (empty($apikey)) { - $this->write($request, Level::Info, 'No webhook token was found in header or query.'); - return new Response(401); + $this->write($request, Level::Info, 'No webhook token was found in header or query.', [ + 'uri' => (string)$request->getUri(), + 'headers' => $request->getHeaders(), + 'query' => $request->getQueryParams(), + ], true); + return api_response([], HTTP_STATUS::HTTP_UNAUTHORIZED); } $validUser = $validUUid = null; @@ -362,7 +371,7 @@ final class Initializer } $this->write($request, $loglevel ?? Level::Error, $message, ['messages' => $log]); - return new Response(401); + return api_response([], HTTP_STATUS::HTTP_UNAUTHORIZED); } // -- sanity check in case user has both import.enabled and options.IMPORT_METADATA_ONLY enabled. @@ -379,7 +388,7 @@ final class Initializer 'backend' => $class->getName() ]); - return new Response(406); + return api_response([], HTTP_STATUS::HTTP_NOT_ACCEPTABLE); } $entity = $class->parseWebhook($request); @@ -403,7 +412,7 @@ final class Initializer ] ); - return new Response(304); + return api_response([], HTTP_STATUS::HTTP_NOT_MODIFIED); } if ((0 === (int)$entity->episode || null === $entity->season) && $entity->isEpisode()) { @@ -422,7 +431,7 @@ final class Initializer ] ); - return new Response(304); + return api_response([], HTTP_STATUS::HTTP_NOT_MODIFIED); } $cache = Container::get(CacheInterface::class); @@ -462,7 +471,7 @@ final class Initializer ] ]); - return new Response(200); + return api_response([], HTTP_STATUS::HTTP_OK); } /** @@ -527,8 +536,14 @@ final class Initializer })(); try { - return $router->dispatch($realRequest); - } catch (RouterHttpException $e) { + $response = $router->dispatch($realRequest); + $this->write($realRequest, Level::Info, 'HTTP: [{status}] \'{uri}\' {method}', [ + 'status' => $response->getStatusCode(), + 'method' => $realRequest->getMethod(), + ], true); + return $response; + } /** @noinspection PhpRedundantCatchClauseInspection */ + catch (RouterHttpException $e) { throw new HttpException($e->getMessage(), $e->getStatusCode()); } } @@ -689,6 +704,7 @@ final class Initializer int|string|Level $level, string $message, array $context = [], + bool $forceContext = false ): void { if (null === $this->accessLog) { return; @@ -709,6 +725,7 @@ final class Initializer $context = array_replace_recursive([ 'request' => [ + 'method' => $request->getMethod(), 'id' => ag($params, 'X_REQUEST_ID'), 'ip' => getClientIp($request), 'agent' => ag($params, 'HTTP_USER_AGENT'), @@ -720,7 +737,7 @@ final class Initializer $context['attributes'] = $attributes; } - if (true === (bool)Config::get('logs.context')) { + if (true === ((bool)Config::get('logs.context') || $forceContext)) { $this->accessLog->log($level, $message, $context); } else { $this->accessLog->log($level, r($message, $context)); diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index f6c2d64f..1ecd3ed8 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -383,7 +383,7 @@ if (!function_exists('api_response')) { /** * Create a API response. * - * @param array $body The JSON data to include in the response body. + * @param array|null $body The JSON data to include in the response body. * @param HTTP_STATUS $status Optional. The HTTP status code. Default is {@see HTTP_STATUS::HTTP_OK}. * @param array $headers Optional. Additional headers to include in the response. * @param string|null $reason Optional. The reason phrase to include in the response. Default is null. @@ -391,7 +391,7 @@ if (!function_exists('api_response')) { * @return ResponseInterface A PSR-7 compatible response object. */ function api_response( - array $body, + array|null $body, HTTP_STATUS $status = HTTP_STATUS::HTTP_OK, array $headers = [], string|null $reason = null @@ -399,10 +399,10 @@ if (!function_exists('api_response')) { return (new Response( status: $status->value, headers: $headers, - body: json_encode( + body: $body ? json_encode( $body, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES - ), + ) : null, reason: $reason, ))->withHeader('Content-Type', 'application/json')->withHeader('X-Application-Version', getAppVersion()); }