Merge pull request #663 from flightphp/download-file

Adjusting PHPStan Linting and updated download()
api-security v3.17.1
n0nag0n 2 weeks ago committed by GitHub
commit 6a2494c698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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 <mike@mikecao.com>
*
* # 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.
*
* # Class registration
* @method EventDispatcher eventDispatcher() Gets event dispatcher
*
* # 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<string, string|array<string>> $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<string,mixed> $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
* @license MIT, https://docs.flightphp.com/license
* @copyright Copyright (c) 2011-2025, Mike Cao <mike@mikecao.com>, n0nag0n <n0nag0n@sky-9.com>
*
* @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)
*
* @phpstan-template EngineTemplate of object
* @phpstan-method void registerContainerHandler(ContainerInterface|callable(class-string<EngineTemplate> $id, array<int|string, mixed> $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<string, string|array<string>> $methods = [])
* @phpstan-method string getUrl(string $alias, array<string, mixed> $params = [])
* @phpstan-method void before(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback)
* @phpstan-method void after(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback)
* @phpstan-method void set(string|iterable<string, mixed> $key, ?mixed $value = null)
* @phpstan-method mixed get(?string $key)
* @phpstan-method void render(string $file, ?array<string, mixed> $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)
*
* 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<EngineTemplate> Method and class dispatcher. */
protected Dispatcher $dispatcher;
/** Event dispatcher. */
@ -370,7 +368,7 @@ class Engine
*
* @param string|iterable<string, mixed> $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);
}
/**

@ -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 <mike@mikecao.com>
* @license MIT, https://docs.flightphp.com/license
* @copyright Copyright (c) 2011-2025, Mike Cao <mike@mikecao.com>, n0nag0n <n0nag0n@sky-9.com>
*
* @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<int, mixed> $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<T> $id, array<int|string, mixed> $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<string, string|array<string>> $methods = [])
* Adds standardized RESTful routes for a controller.
* @method static Router router() Returns Router instance.
* @method static string getUrl(string $alias, array<string, mixed> $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<int, mixed> &$params, string &$output): (void|false) $callback)
* Adds a filter before a framework method.
* @method static void after(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback)
* Adds a filter after a framework method.
*
* # Variables
* @method static void set(string|iterable<string, mixed> $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.
*
* # Views
* @method static void render(string $file, ?array<string, mixed> $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
* @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)
*
* @phpstan-template FlightTemplate of object
* @phpstan-method static void register(string $name, class-string<FlightTemplate> $class, array<int|string, mixed> $params = [], (callable(class-string<FlightTemplate> $class, array<int|string, mixed> $params): void)|null $callback = null)
* @phpstan-method static void registerContainerHandler(ContainerInterface|callable(class-string<FlightTemplate> $id, array<int|string, mixed> $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<string, string|array<string>> $methods = [])
* @phpstan-method static string getUrl(string $alias, array<string, mixed> $params = [])
* @phpstan-method static void before(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback)
* @phpstan-method static void after(string $name, Closure(array<int, mixed> &$params, string &$output): (void|false) $callback)
* @phpstan-method static void set(string|iterable<string, mixed> $key, mixed $value)
* @phpstan-method static mixed get(?string $key)
* @phpstan-method static void render(string $file, ?array<string, mixed> $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)
*
* 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<FlightTemplate>
*/
private static Engine $engine;
/**
@ -138,7 +124,7 @@ class Flight
return self::app()->{$name}(...$params);
}
/** @return Engine Application instance */
/** @return Engine<FlightTemplate> 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<FlightTemplate> $engine Vroom vroom!
*/
public static function setEngine(Engine $engine): void
{

@ -20,6 +20,7 @@ use TypeError;
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @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<EngineTemplate> $engine Engine instance. */
protected ?Engine $engine = null;
/** @var array<string, callable(): (void|mixed)> Mapped events. */
@ -77,6 +78,13 @@ class Dispatcher
);
}
/**
* Sets the engine instance
*
* @param Engine<EngineTemplate> $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<int, mixed> $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<int, mixed> &$params
*
* @return $this
* @throws Exception
*
* @return $this
*/
protected function runPreFilters(string $eventName, array &$params): self
{

@ -100,9 +100,9 @@ class PdoWrapper extends PDO
* @param string $sql - Ex: "SELECT * FROM table WHERE something = ?"
* @param array<int|string,mixed> $params - Ex: [ $something ]
*
* @return Collection
* @return Collection|array<mixed,mixed>
*/
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<int|string,mixed> $params - Ex: [ $something ]
*
* @return array<int,Collection>
* @return array<int,Collection|array<string,mixed>>
*/
public function fetchAll(string $sql, array $params = [])
{

@ -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 or is an empty string, 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,16 @@ 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);
}
$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');

@ -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() {

Loading…
Cancel
Save