From 053227b3b0ecd5ae223daf64e71a54cecbabf304 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 30 Sep 2025 17:33:24 +0100 Subject: [PATCH 1/2] adjusted phpstan linting to be friendlier and changed download() to also specify file name --- flight/Engine.php | 113 +++++++++++++------------- flight/Flight.php | 142 +++++++++++++++------------------ flight/core/Dispatcher.php | 16 +++- flight/database/PdoWrapper.php | 6 +- flight/net/Request.php | 50 ++++++------ flight/net/Response.php | 13 ++- 6 files changed, 171 insertions(+), 169 deletions(-) diff --git a/flight/Engine.php b/flight/Engine.php index 5974158..858ab42 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -24,60 +24,58 @@ use Psr\Container\ContainerInterface; * It is responsible for loading an HTTP request, running the assigned services, * and generating an HTTP response. * - * @license MIT, http://flightphp.com/license - * @copyright Copyright (c) 2011, Mike Cao + * @license MIT, https://docs.flightphp.com/license + * @copyright Copyright (c) 2011-2025, Mike Cao , n0nag0n * - * # Core methods - * @method void start() Starts engine - * @method void stop() Stops framework and outputs current response - * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) Stops processing and returns a given response. + * @method void start() + * @method void stop() + * @method void halt(int $code = 200, string $message = '', bool $actuallyExit = true) + * @method EventDispatcher eventDispatcher() + * @method Route route(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method void group(string $pattern, callable $callback, array $group_middlewares = []) + * @method Route post(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method Route put(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method Route patch(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method Route delete(string $pattern, callable|string|array $callback, bool $pass_route = false, string $alias = '') + * @method void resource(string $pattern, string $controllerClass, array $methods = []) + * @method Router router() + * @method string getUrl(string $alias) + * @method void render(string $file, array $data = null, string $key = null) + * @method View view() + * @method void onEvent(string $event, callable $callback) + * @method void triggerEvent(string $event, ...$args) + * @method Request request() + * @method Response response() + * @method void error(Throwable $e) + * @method void notFound() + * @method void redirect(string $url, int $code = 303) + * @method void json($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method void jsonHalt($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method void jsonp($data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method void etag(string $id, string $type = 'strong') + * @method void lastModified(int $time) + * @method void download(string $filePath) * - * # Class registration - * @method EventDispatcher eventDispatcher() Gets event dispatcher + * @phpstan-template EngineTemplate of object + * @phpstan-method void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?EngineTemplate $containerHandler) + * @phpstan-method Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) + * @phpstan-method Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method void resource(string $pattern, class-string $controllerClass, array> $methods = []) + * @phpstan-method string getUrl(string $alias, array $params = []) + * @phpstan-method void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method void set(string|iterable $key, ?mixed $value = null) + * @phpstan-method mixed get(?string $key) + * @phpstan-method void render(string $file, ?array $data = null, ?string $key = null) + * @phpstan-method void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @phpstan-method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @phpstan-method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * - * # Routing - * @method Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a URL to a callback function with all applicable methods - * @method void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) - * Groups a set of routes together under a common prefix. - * @method Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a POST URL to a callback function. - * @method Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a PUT URL to a callback function. - * @method Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a PATCH URL to a callback function. - * @method Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a DELETE URL to a callback function. - * @method void resource(string $pattern, class-string $controllerClass, array> $methods = []) - * Adds standardized RESTful routes for a controller. - * @method Router router() Gets router - * @method string getUrl(string $alias) Gets a url from an alias - * - * # Views - * @method void render(string $file, ?array $data = null, ?string $key = null) Renders template - * @method View view() Gets current view - * - * # Events - * @method void onEvent(string $event, callable $callback) Registers a callback for an event. - * @method void triggerEvent(string $event, ...$args) Triggers an event. - * - * # Request-Response - * @method Request request() Gets current request - * @method Response response() Gets current response - * @method void error(Throwable $e) Sends an HTTP 500 response for any errors. - * @method void notFound() Sends an HTTP 404 response when a URL is not found. - * @method void redirect(string $url, int $code = 303) Redirects the current request to another URL. - * @method void json(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) - * Sends a JSON response. - * @method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) - * Sends a JSON response and immediately halts the request. - * @method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) - * Sends a JSONP response. - * - * # HTTP methods - * @method void etag(string $id, ('strong'|'weak') $type = 'strong') Handles ETag HTTP caching. - * @method void lastModified(int $time) Handles last modified HTTP caching. - * @method void download(string $filePath) Downloads a file + * Note: IDEs will use standard @method tags for autocompletion, while PHPStan will use @phpstan-* tags for advanced type checking. * * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore */ @@ -118,7 +116,7 @@ class Engine /** Class loader. */ protected Loader $loader; - /** Method and class dispatcher. */ + /** @var Dispatcher Method and class dispatcher. */ protected Dispatcher $dispatcher; /** Event dispatcher. */ @@ -370,7 +368,7 @@ class Engine * * @param string|iterable $key * Variable name as `string` or an iterable of `'varName' => $varValue` - * @param mixed $value Ignored if `$key` is an `iterable` + * @param ?mixed $value Ignored if `$key` is an `iterable` */ public function set($key, $value = null): void { @@ -981,14 +979,15 @@ class Engine * Downloads a file * * @param string $filePath The path to the file to download + * @param string $fileName The name the file should be downloaded as * * @throws Exception If the file cannot be found * * @return void */ - public function _download(string $filePath): void + public function _download(string $filePath, string $fileName = ''): void { - $this->response()->downloadFile($filePath); + $this->response()->downloadFile($filePath, $fileName); } /** @@ -1020,8 +1019,8 @@ class Engine public function _lastModified(int $time): void { $this->response()->header('Last-Modified', gmdate('D, d M Y H:i:s \G\M\T', $time)); - $request = $this->request(); - $ifModifiedSince = $request->header('If-Modified-Since'); + $request = $this->request(); + $ifModifiedSince = $request->header('If-Modified-Since'); $hit = isset($ifModifiedSince) && strtotime($ifModifiedSince) === $time; $this->triggerEvent('flight.cache.checked', 'lastModified', $hit, 0.0); diff --git a/flight/Flight.php b/flight/Flight.php index 1c64826..2b69e16 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -16,90 +16,76 @@ require_once __DIR__ . '/autoload.php'; /** * The Flight class is a static representation of the framework. * - * @license MIT, http://flightphp.com/license - * @copyright Copyright (c) 2011, Mike Cao + * @license MIT, https://docs.flightphp.com/license + * @copyright Copyright (c) 2011-2025, Mike Cao , n0nag0n * - * @template T of object - * - * # Core methods - * @method static void start() Starts the framework. - * @method static void path(string $dir) Adds a path for autoloading classes. - * @method static void stop(?int $code = null) Stops the framework and sends a response. + * @method static void start() + * @method static void path(string $dir) + * @method static void stop(int $code = null) * @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true) - * Stop the framework with an optional status code and message. - * @method static void register(string $name, string $class, array $params = [], ?callable $callback = null) - * Registers a class to a framework method. + * @method static void register(string $name, string $class, array $params = [], callable $callback = null) * @method static void unregister(string $methodName) - * Unregisters a class to a framework method. - * @method static void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?T $containerHandler) Registers a container handler. - * - * # Class registration - * @method EventDispatcher eventDispatcher() Gets event dispatcher - * - * # Class registration - * @method EventDispatcher eventDispatcher() Gets event dispatcher - * - * # Routing - * @method static Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Maps a URL pattern to a callback with all applicable methods. - * @method static void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) - * Groups a set of routes together under a common prefix. - * @method static Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a POST URL to a callback function. - * @method static Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a PUT URL to a callback function. - * @method static Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a PATCH URL to a callback function. - * @method static Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') - * Routes a DELETE URL to a callback function. - * @method static void resource(string $pattern, class-string $controllerClass, array> $methods = []) - * Adds standardized RESTful routes for a controller. - * @method static Router router() Returns Router instance. - * @method static string getUrl(string $alias, array $params = []) Gets a url from an alias - * @method static void map(string $name, callable $callback) Creates a custom framework method. - * - * # Filters - * @method static void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) - * Adds a filter before a framework method. - * @method static void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) - * Adds a filter after a framework method. + * @method static void registerContainerHandler($containerHandler) + * @method static EventDispatcher eventDispatcher() + * @method static Route route(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static void group(string $pattern, callable $callback, array $group_middlewares = []) + * @method static Route post(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route put(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route patch(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static Route delete(string $pattern, callable $callback, bool $pass_route = false, string $alias = '') + * @method static void resource(string $pattern, string $controllerClass, array $methods = []) + * @method static Router router() + * @method static string getUrl(string $alias, array $params = []) + * @method static void map(string $name, callable $callback) + * @method static void before(string $name, Closure $callback) + * @method static void after(string $name, Closure $callback) + * @method static void set($key, $value) + * @method static mixed get($key = null) + * @method static bool has(string $key) + * @method static void clear($key = null) + * @method static void render(string $file, array $data = null, string $key = null) + * @method static View view() + * @method void onEvent(string $event, callable $callback) + * @method void triggerEvent(string $event, ...$args) + * @method static Request request() + * @method static Response response() + * @method static void redirect(string $url, int $code = 303) + * @method static void json($data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @method static void jsonHalt($data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method static void jsonp($data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @method static void error(Throwable $exception) + * @method static void notFound() + * @method static void etag(string $id, string $type = 'strong') + * @method static void lastModified(int $time) + * @method static void download(string $filePath) * - * # Variables - * @method static void set(string|iterable $key, mixed $value) Sets a variable. - * @method static mixed get(?string $key) Gets a variable. - * @method static bool has(string $key) Checks if a variable is set. - * @method static void clear(?string $key = null) Clears a variable. + * @phpstan-template FlightTemplate of object + * @phpstan-method static void register(string $name, class-string $class, array $params = [], (callable(class-string $class, array $params): void)|null $callback = null) + * @phpstan-method static void registerContainerHandler(ContainerInterface|callable(class-string $id, array $params): ?FlightTemplate $containerHandler) + * @phpstan-method static Route route(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static void group(string $pattern, callable $callback, (class-string|callable|array{0: class-string, 1: string})[] $group_middlewares = []) + * @phpstan-method static Route post(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static Route put(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static Route patch(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static Route delete(string $pattern, callable|string|array{0: class-string, 1: string} $callback, bool $pass_route = false, string $alias = '') + * @phpstan-method static void resource(string $pattern, class-string $controllerClass, array> $methods = []) + * @phpstan-method static string getUrl(string $alias, array $params = []) + * @phpstan-method static void before(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method static void after(string $name, Closure(array &$params, string &$output): (void|false) $callback) + * @phpstan-method static void set(string|iterable $key, mixed $value) + * @phpstan-method static mixed get(?string $key) + * @phpstan-method static void render(string $file, ?array $data = null, ?string $key = null) + * @phpstan-method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) + * @phpstan-method static void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @phpstan-method static void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * - * # Views - * @method static void render(string $file, ?array $data = null, ?string $key = null) - * Renders a template file. - * @method static View view() Returns View instance. - * - * # Events - * @method void onEvent(string $event, callable $callback) Registers a callback for an event. - * @method void triggerEvent(string $event, ...$args) Triggers an event. - * - * # Request-Response - * @method static Request request() Returns Request instance. - * @method static Response response() Returns Response instance. - * @method static void redirect(string $url, int $code = 303) Redirects to another URL. - * @method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) - * Sends a JSON response. - * @method static void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) - * Sends a JSON response and immediately halts the request. - * @method static void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) - * Sends a JSONP response. - * @method static void error(Throwable $exception) Sends an HTTP 500 response. - * @method static void notFound() Sends an HTTP 404 response. - * - * # HTTP methods - * @method static void etag(string $id, ('strong'|'weak') $type = 'strong') Performs ETag HTTP caching. - * @method static void lastModified(int $time) Performs last modified HTTP caching. - * @method static void download(string $filePath) Downloads a file + * Note: IDEs will use standard @method tags for autocompletion, while PHPStan will use @phpstan-* tags for advanced type checking. */ class Flight { - /** Framework engine. */ + /** + * @var Engine + */ private static Engine $engine; /** @@ -138,7 +124,7 @@ class Flight return self::app()->{$name}(...$params); } - /** @return Engine Application instance */ + /** @return Engine Application instance */ public static function app(): Engine { return self::$engine ?? self::$engine = new Engine(); @@ -147,7 +133,7 @@ class Flight /** * Set the engine instance * - * @param Engine $engine Vroom vroom! + * @param Engine $engine Vroom vroom! */ public static function setEngine(Engine $engine): void { diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 9d1873d..2d45a49 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -20,6 +20,7 @@ use TypeError; * * @license MIT, http://flightphp.com/license * @copyright Copyright (c) 2011, Mike Cao + * @phpstan-template EngineTemplate of object */ class Dispatcher { @@ -29,7 +30,7 @@ class Dispatcher /** Exception message if thrown by setting the container as a callable method. */ protected ?Throwable $containerException = null; - /** @var ?Engine $engine Engine instance. */ + /** @var ?Engine $engine Engine instance. */ protected ?Engine $engine = null; /** @var array Mapped events. */ @@ -77,6 +78,13 @@ class Dispatcher ); } + /** + * Sets the engine instance + * + * @param Engine $engine Flight instance + * + * @return void + */ public function setEngine(Engine $engine): void { $this->engine = $engine; @@ -88,8 +96,9 @@ class Dispatcher * @param string $name Event name. * @param array $params Callback parameters. * - * @return mixed Output of callback * @throws Exception If event name isn't found or if event throws an `Exception`. + * + * @return mixed Output of callback */ public function run(string $name, array $params = []) { @@ -102,8 +111,9 @@ class Dispatcher /** * @param array &$params * - * @return $this * @throws Exception + * + * @return $this */ protected function runPreFilters(string $eventName, array &$params): self { diff --git a/flight/database/PdoWrapper.php b/flight/database/PdoWrapper.php index 7cf74cf..ff13a4c 100644 --- a/flight/database/PdoWrapper.php +++ b/flight/database/PdoWrapper.php @@ -100,9 +100,9 @@ class PdoWrapper extends PDO * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" * @param array $params - Ex: [ $something ] * - * @return Collection + * @return Collection|array */ - public function fetchRow(string $sql, array $params = []): Collection + public function fetchRow(string $sql, array $params = []) { $sql .= stripos($sql, 'LIMIT') === false ? ' LIMIT 1' : ''; $result = $this->fetchAll($sql, $params); @@ -120,7 +120,7 @@ class PdoWrapper extends PDO * @param string $sql - Ex: "SELECT * FROM table WHERE something = ?" * @param array $params - Ex: [ $something ] * - * @return array + * @return array> */ public function fetchAll(string $sql, array $params = []) { diff --git a/flight/net/Request.php b/flight/net/Request.php index 5d0c9b5..8368c28 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -156,16 +156,16 @@ class Request { // Default properties if (empty($config) === true) { - $scheme = $this->getScheme(); - $url = $this->getVar('REQUEST_URI', '/'); - if (strpos($url, '@') !== false) { - $url = str_replace('@', '%40', $url); - } - $base = $this->getVar('SCRIPT_NAME', ''); - if (strpos($base, ' ') !== false || strpos($base, '\\') !== false) { - $base = str_replace(['\\', ' '], ['/', '%20'], $base); - } - $base = dirname($base); + $scheme = $this->getScheme(); + $url = $this->getVar('REQUEST_URI', '/'); + if (strpos($url, '@') !== false) { + $url = str_replace('@', '%40', $url); + } + $base = $this->getVar('SCRIPT_NAME', ''); + if (strpos($base, ' ') !== false || strpos($base, '\\') !== false) { + $base = str_replace(['\\', ' '], ['/', '%20'], $base); + } + $base = dirname($base); $config = [ 'url' => $url, 'base' => $base, @@ -261,9 +261,9 @@ class Request $method = $this->method ?? $this->getMethod(); - if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true) === true) { - $body = file_get_contents($this->stream_path); - } + if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true) === true) { + $body = file_get_contents($this->stream_path); + } $this->body = $body; @@ -305,7 +305,7 @@ class Request $flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE; foreach ($forwarded as $key) { - $serverVar = self::getVar($key); + $serverVar = self::getVar($key); if ($serverVar !== '') { sscanf($serverVar, '%[^,]', $ip); if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) { @@ -414,17 +414,17 @@ class Request */ public static function parseQuery(string $url): array { - $queryPos = strpos($url, '?'); - if ($queryPos === false) { - return []; - } - $query = substr($url, $queryPos + 1); - if ($query === '') { - return []; - } - $params = []; - parse_str($query, $params); - return $params; + $queryPos = strpos($url, '?'); + if ($queryPos === false) { + return []; + } + $query = substr($url, $queryPos + 1); + if ($query === '') { + return []; + } + $params = []; + parse_str($query, $params); + return $params; } /** diff --git a/flight/net/Response.php b/flight/net/Response.php index b364b13..e3b3cc6 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -324,7 +324,7 @@ class Response ); // @codeCoverageIgnoreEnd } else { - $serverProtocol = Request::getVar('SERVER_PROTOCOL') ?: 'HTTP/1.1'; + $serverProtocol = Request::getVar('SERVER_PROTOCOL') ?: 'HTTP/1.1'; $this->setRealHeader( sprintf( '%s %d %s', @@ -484,10 +484,13 @@ class Response * Downloads a file. * * @param string $filePath The path to the file to be downloaded. + * @param string $fileName The name the downloaded file should have. If not provided, the name of the file on disk will be used. + * + * @throws Exception If the file cannot be found. * * @return void */ - public function downloadFile(string $filePath): void + public function downloadFile(string $filePath, string $fileName = ''): void { if (file_exists($filePath) === false) { throw new Exception("$filePath cannot be found."); @@ -498,10 +501,14 @@ class Response $mimeType = mime_content_type($filePath); $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; + if ($fileName === '') { + $fileName = basename($filePath); + } + $this->send(); $this->setRealHeader('Content-Description: File Transfer'); $this->setRealHeader('Content-Type: ' . $mimeType); - $this->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); + $this->setRealHeader('Content-Disposition: attachment; filename="' . $fileName . '"'); $this->setRealHeader('Expires: 0'); $this->setRealHeader('Cache-Control: must-revalidate'); $this->setRealHeader('Pragma: public'); From 4d9f0f42bdfb2929994d7ee8a7620dbfd7d48478 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Tue, 30 Sep 2025 18:15:10 +0100 Subject: [PATCH 2/2] Added response test and did some sanitizing --- flight/net/Response.php | 4 +++- tests/EngineTest.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index e3b3cc6..9587ddf 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -484,7 +484,7 @@ class Response * Downloads a file. * * @param string $filePath The path to the file to be downloaded. - * @param string $fileName The name the downloaded file should have. If not provided, the name of the file on disk will be used. + * @param string $fileName The name the downloaded file should have. If not provided or is an empty string, the name of the file on disk will be used. * * @throws Exception If the file cannot be found. * @@ -501,6 +501,8 @@ class Response $mimeType = mime_content_type($filePath); $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; + // Sanitize filename to prevent header injection + $fileName = str_replace(["\r", "\n", '"'], '', $fileName); if ($fileName === '') { $fileName = basename($filePath); } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 256efb5..4b3dbc9 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -1086,11 +1086,13 @@ class EngineTest extends TestCase // doing this so we can overwrite some parts of the response $engine->getLoader()->register('response', function () { return new class extends Response { + public $headersSent = []; public function setRealHeader( string $header_string, bool $replace = true, int $response_code = 0 ): self { + $this->headersSent[] = $header_string; return $this; } }; @@ -1100,6 +1102,37 @@ class EngineTest extends TestCase $streamPath = stream_get_meta_data($tmpfile)['uri']; $this->expectOutputString('I am a teapot'); $engine->download($streamPath); + $this->assertContains('Content-Disposition: attachment; filename="'.basename($streamPath).'"', $engine->response()->headersSent); + } + + public function testDownloadWithDefaultFileName(): void + { + $engine = new class extends Engine { + public function getLoader() + { + return $this->loader; + } + }; + // doing this so we can overwrite some parts of the response + $engine->getLoader()->register('response', function () { + return new class extends Response { + public $headersSent = []; + public function setRealHeader( + string $header_string, + bool $replace = true, + int $response_code = 0 + ): self { + $this->headersSent[] = $header_string; + return $this; + } + }; + }); + $tmpfile = tmpfile(); + fwrite($tmpfile, 'I am a teapot'); + $streamPath = stream_get_meta_data($tmpfile)['uri']; + $this->expectOutputString('I am a teapot'); + $engine->download($streamPath, 'something.txt'); + $this->assertContains('Content-Disposition: attachment; filename="something.txt"', $engine->response()->headersSent); } public function testDownloadBadPath() {