Merge branch 'master' into replace-table-with-chart-in-readme

pull/718/head
n0nag0n 1 day ago committed by GitHub
commit cb27287bbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -16,11 +16,11 @@ This is the main FlightPHP core library for building fast, simple, and extensibl
- Run tests: `composer test` (uses phpunit/phpunit and spatie/phpunit-watcher)
- Run test server: `composer test-server` or `composer test-server-v2`
- Lint code & Check code style: `composer lint` (uses phpstan/phpstan, level 6)
- Beautify code: `composer format` (uses squizlabs/php_codesniffer, PSR1)
- Beautify code: `composer format` (uses squizlabs/php_codesniffer, PSR12)
- Test coverage: `composer test-coverage`
## Coding Standards
- Follow PSR1 coding standards (enforced by PHPCS)
- Follow PSR12 coding standards (enforced by PHPCS)
- Use strict comparisons (`===`, `!==`)
- PHPStan level 6 compliance
- Focus on PHP 7.4 compatibility (avoid PHP 8+ only features)

@ -16,11 +16,11 @@ This is the main FlightPHP core library for building fast, simple, and extensibl
- Run tests: `composer test` (uses phpunit/phpunit and spatie/phpunit-watcher)
- Run test server: `composer test-server` or `composer test-server-v2`
- Lint code & Check code style: `composer lint` (uses phpstan/phpstan, level 6)
- Beautify code: `composer format` (uses squizlabs/php_codesniffer, PSR1)
- Beautify code: `composer format` (uses squizlabs/php_codesniffer, PSR12)
- Test coverage: `composer test-coverage`
## Coding Standards
- Follow PSR1 coding standards (enforced by PHPCS)
- Follow PSR12 coding standards (enforced by PHPCS)
- Use strict comparisons (`===`, `!==`)
- PHPStan level 6 compliance
- Focus on PHP 7.4 compatibility (avoid PHP 8+ only features)

@ -16,11 +16,11 @@ This is the main FlightPHP core library for building fast, simple, and extensibl
- Run tests: `composer test` (uses phpunit/phpunit and spatie/phpunit-watcher)
- Run test server: `composer test-server` or `composer test-server-v2`
- Lint code & Check code style: `composer lint` (uses phpstan/phpstan, level 6)
- Beautify code: `composer format` (uses squizlabs/php_codesniffer, PSR1)
- Beautify code: `composer format` (uses squizlabs/php_codesniffer, PSR12)
- Test coverage: `composer test-coverage`
## Coding Standards
- Follow PSR1 coding standards (enforced by PHPCS)
- Follow PSR12 coding standards (enforced by PHPCS)
- Use strict comparisons (`===`, `!==`)
- PHPStan level 6 compliance
- Focus on PHP 7.4 compatibility (avoid PHP 8+ only features)

@ -9,19 +9,19 @@ Flight aims to be simple and fast. Anything that compromises either of those two
* **Dependencies** - We strive to be dependency free in Flight. Yes even polyfills, yes even `Interface` only repos like `psr/container`. The fewer dependencies, the fewer your exposed attack vectors.
* **Coding Standards** - We use PSR1 coding standards enforced by PHPCS. Some standards that either need additional configuration or need to be manually done are:
* **Coding Standards** - We use PSR-12 coding standards enforced by PHPCS. Some standards that either need additional configuration or need to be manually done are:
* PHPStan is at level 6.
* `===` instead of truthy or falsey statements like `==` or `!is_array()`.
* `===` instead of truthy or falsy statements like `==`.
* **PHP 7.4 Focused** - We do not make PHP 8+ focused enhancements on the framework as the focus is maintaining PHP 7.4.
* **Core functionality vs Plugin** - Have a conversation with us in the [chatroom](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host) to know if your idea is worth makes sense in the framework or in a plugin.
* **Testing** - Until automated testing is put into place, any PRs must pass unit testing in PHP 7.4 and PHP 8.2+. Additionally you need to run `composer test-server` and `composer test-server-v2` and ensure all the header links work correctly.
* **Testing** - PRs must pass unit tests on PHP 7.4 through PHP 8.5+. Additionally you need to run `composer test-server` and `composer test-server-v2` and ensure all the header links work correctly.
#### **Did you find a bug?**
* **Do not open up a GitHub issue if the bug is a security vulnerability**. Instead contact maintainers directly via email to safely pass in the information related to the security vuln.
* **Do not open up a GitHub issue if the bug is a security vulnerability**. Instead contact maintainers directly via email to safely share details about the security vulnerability.
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/flightphp/core/issues).

@ -22,10 +22,7 @@ composer require flightphp/core
or you can download a zip of this repo. Then you would have a basic `index.php` file like the following:
```php
// if installed with composer
require 'vendor/autoload.php';
// or if installed manually by zip file
// require 'flight/Flight.php';
require 'flight/autoload.php';
Flight::route('/', function () {
echo 'hello world!';
@ -78,9 +75,10 @@ If you have a current project on v2, you should be able to upgrade to v3 with no
> [!IMPORTANT]
> Flight requires `PHP 7.4` or greater.
**Note:** PHP 7.4 is supported because at the current time of writing (2024) PHP 7.4 is the default version for some LTS Linux distributions. Forcing a move to PHP >8 would cause a lot of heartburn for those users.
The framework also supports PHP >8.
> [!NOTE]
> PHP 7.4 is supported because at the current time of writing (2024) PHP 7.4 is the default version for some LTS Linux distributions.
> Forcing a move to PHP 8 would cause a lot of heartburn for those users.
> The framework also supports PHP 8.
# Roadmap

@ -24,8 +24,7 @@ use Psr\Container\ContainerInterface;
* It is responsible for loading an HTTP request, running the assigned services,
* and generating an HTTP response.
*
* @license MIT, https://docs.flightphp.com/license
* @copyright Copyright (c) 2011-2025, Mike Cao <mike@mikecao.com>, n0nag0n <n0nag0n@sky-9.com>
* @copyright 2011-2026, Mike Cao https://mikecao.com, n0nag0n <n0nag0n@sky-9.com>
*
* @method void start()
* @method void stop()
@ -75,10 +74,7 @@ use Psr\Container\ContainerInterface;
* @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
* @license https://docs.flightphp.com/license MIT
*/
class Engine
{
@ -227,8 +223,24 @@ class Engine
// which causes a lot of problems. This will be removed
// in v4
$self->response()->v2_output_buffering = $this->get('flight.v2.output_buffering');
// Propagate method override setting to Request
$self->request()::$allowMethodOverride = (bool) $self->get('flight.allow_method_override');
// Propagate method override setting to Request.
// Assign the static on the class first — do not call request() before this,
// or Request's constructor caches the method while the flag is still the default (true).
Request::$allowMethodOverride = (bool) $self->get('flight.allow_method_override');
// If a Request was already built earlier (common: apps touch request() before start)
// while override was still enabled, refresh the cached verb when override is off
// and an override input is present. When no override input exists, leave any
// intentional manual method assignment alone.
if (Request::$allowMethodOverride === false) {
$hasOverrideInput = Request::getVar('HTTP_X_HTTP_METHOD_OVERRIDE') !== ''
|| isset($_REQUEST['_method']);
if ($hasOverrideInput === true) {
$request = $self->request();
$request->method = Request::getMethod();
}
}
});
$this->initialized = true;

@ -15,7 +15,7 @@ use Psr\Container\ContainerInterface;
* The Flight class is a static representation of the framework.
*
* @license MIT, https://docs.flightphp.com/license
* @copyright Copyright (c) 2011-2025, Mike Cao <mike@mikecao.com>, n0nag0n <n0nag0n@sky-9.com>
* @copyright 2011-2026, Mike Cao https://mikecao.com, n0nag0n <n0nag0n@sky-9.com>
*
* @method static void start()
* @method static void path(string $dir)
@ -77,11 +77,8 @@ use Psr\Container\ContainerInterface;
* @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.
* @license https://docs.flightphp.com/license MIT
*/
// phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNamespace
class Flight
{
/**

@ -18,8 +18,8 @@ use TypeError;
* allows you to hook other functions to an event that can modify the
* input parameters and/or the output.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @license https://docs.flightphp.com/license MIT
* @phpstan-template EngineTemplate of object
*/
class Dispatcher

@ -6,134 +6,83 @@ namespace flight\core;
class EventDispatcher
{
/** @var self|null Singleton instance of the EventDispatcher */
private static ?self $instance = null;
/** @var array<string, array<int, callable>> */
/** @var array<string, callable[]> */
protected array $listeners = [];
/**
* Singleton instance of the EventDispatcher.
*
* @return self
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register a callback for an event.
*
* @param string $event Event name
* @param callable $callback Callback function
*/
public function on(string $event, callable $callback): void
{
if (isset($this->listeners[$event]) === false) {
$this->listeners[$event] = [];
}
$this->listeners[$event] ??= [];
$this->listeners[$event][] = $callback;
}
/**
* Trigger an event with optional arguments.
*
* @param string $event Event name
* @param mixed ...$args Arguments to pass to the callbacks
*
* @param mixed ...$args Arguments to pass to the listeners.
* @return mixed
*/
public function trigger(string $event, ...$args)
{
$result = null;
if (isset($this->listeners[$event]) === true) {
foreach ($this->listeners[$event] as $callback) {
$result = call_user_func_array($callback, $args);
// If you return false, it will break the loop and stop the other event listeners.
if ($result === false) {
break; // Stop executing further listeners
}
$listenerReturnValue = null;
foreach ($this->getListeners($event) as $listener) {
$listenerReturnValue = $listener(...$args);
if ($listenerReturnValue === false) {
break;
}
}
return $result;
return $listenerReturnValue;
}
/**
* Check if an event has any registered listeners.
*
* @param string $event Event name
*
* @return bool True if the event has listeners, false otherwise
*/
public function hasListeners(string $event): bool
{
return isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0;
return (
isset($this->listeners[$event])
&& is_array($this->listeners[$event])
&& count($this->listeners[$event])
);
}
/**
* Get all listeners registered for a specific event.
*
* @param string $event Event name
*
* @return array<int, callable> Array of callbacks registered for the event
*/
/** @return callable[] */
public function getListeners(string $event): array
{
return $this->listeners[$event] ?? [];
}
/**
* Get a list of all events that have registered listeners.
*
* @return array<int, string> Array of event names
*/
/** @return string[] */
public function getAllRegisteredEvents(): array
{
return array_keys($this->listeners);
}
/**
* Remove a specific listener for an event.
*
* @param string $event the event name
* @param callable $callback the exact callback to remove
*
* @return void
*/
public function removeListener(string $event, callable $callback): void
{
if (isset($this->listeners[$event]) === true && count($this->listeners[$event]) > 0) {
$this->listeners[$event] = array_filter($this->listeners[$event], function ($listener) use ($callback) {
return $listener !== $callback;
});
$this->listeners[$event] = array_values($this->listeners[$event]); // Re-index the array
if (!$this->hasListeners($event)) {
return;
}
$this->listeners[$event] = array_values(array_filter(
$this->getListeners($event),
static fn(callable $listener): bool => $listener !== $callback,
));
}
/**
* Remove all listeners for a specific event.
*
* @param string $event the event name
*
* @return void
*/
public function removeAllListeners(string $event): void
{
if (isset($this->listeners[$event]) === true) {
unset($this->listeners[$event]);
}
}
/**
* Remove the current singleton instance of the EventDispatcher.
*
* @return void
*/
public static function resetInstance(): void
{
self::$instance = null;

@ -13,8 +13,8 @@ use Exception;
* instances with custom initialization parameters. It also performs
* class autoloading.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @license https://docs.flightphp.com/license MIT
*/
class Loader
{

@ -11,8 +11,8 @@ use flight\util\Collection;
* all the super globals $_GET, $_POST, $_COOKIE, and $_FILES
* are stored and accessible via the Request object.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @license https://docs.flightphp.com/license MIT
*
* The default request properties are:
*

@ -12,8 +12,8 @@ use flight\core\EventDispatcher;
* contains the response headers, HTTP status code, and response
* body.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @license https://docs.flightphp.com/license MIT
*/
class Response
{

@ -9,8 +9,8 @@ namespace flight\net;
* an assigned callback function. The Router tries to match the
* requested URL against a series of URL patterns.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @license https://docs.flightphp.com/license MIT
*/
class Route
{

@ -12,8 +12,8 @@ use flight\net\Route;
* an assigned callback function. The Router tries to match the
* requested URL against a series of URL patterns.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @license https://docs.flightphp.com/license MIT
*/
class Router
{

@ -9,8 +9,8 @@ namespace flight\template;
* methods for managing view data and inserts the data into
* view templates upon rendering.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @license https://docs.flightphp.com/license MIT
*/
class View
{

@ -13,10 +13,10 @@ use JsonSerializable;
* The Collection class allows you to access a set of data
* using both array and object notation.
*
* @license MIT, http://flightphp.com/license
* @copyright Copyright (c) 2011, Mike Cao <mike@mikecao.com>
* @copyright 2011 Mike Cao https://mikecao.com
* @implements ArrayAccess<string, mixed>
* @implements Iterator<string, mixed>
* @license https://docs.flightphp.com/license MIT
*/
class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable
{

@ -23,11 +23,18 @@ class EngineTest extends TestCase
public function setUp(): void
{
$_SERVER = [];
$_REQUEST = [];
$_GET = [];
$_POST = [];
// Static flag leaks across tests (and across Engine instances).
Request::$allowMethodOverride = true;
}
public function tearDown(): void
{
$_SERVER = [];
$_REQUEST = [];
Request::$allowMethodOverride = true;
}
public function testInitBeforeStart(): void
@ -1207,4 +1214,170 @@ class EngineTest extends TestCase
$this->expectExceptionMessage("/path/to/nowhere cannot be found.");
$engine->download('/path/to/nowhere');
}
/**
* Regression for GHSA method-override opt-out: setting the flag to false must
* prevent X-HTTP-Method-Override from selecting a different route.
*
* The original mitigation assigned Request::$allowMethodOverride via
* $self->request()::$allowMethodOverride, which built Request (and cached the
* overridden verb) before the static was set.
*/
public function testAllowMethodOverrideFalseBlocksHeaderOverrideOnStart(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/test';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'DELETE';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['HTTP_HOST'] = 'localhost';
$engine = new Engine();
$engine->set('flight.allow_method_override', false);
$hit = null;
$engine->route('GET /test', function () use (&$hit) {
$hit = 'get';
echo 'get';
});
$engine->route('DELETE /test', function () use (&$hit) {
$hit = 'delete';
echo 'delete';
});
$this->expectOutputString('get');
$engine->start();
$this->assertSame('get', $hit);
$this->assertFalse(Request::$allowMethodOverride);
$this->assertSame('GET', $engine->request()->method);
}
public function testAllowMethodOverrideFalseBlocksPostMethodFieldOnStart(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/test';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['HTTP_HOST'] = 'localhost';
$_REQUEST['_method'] = 'PUT';
$engine = new Engine();
$engine->set('flight.allow_method_override', false);
$hit = null;
$engine->route('POST /test', function () use (&$hit) {
$hit = 'post';
echo 'post';
});
$engine->route('PUT /test', function () use (&$hit) {
$hit = 'put';
echo 'put';
});
$this->expectOutputString('post');
$engine->start();
$this->assertSame('post', $hit);
$this->assertSame('POST', $engine->request()->method);
}
public function testAllowMethodOverrideTrueStillHonorsHeaderOnStart(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/test';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'DELETE';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['HTTP_HOST'] = 'localhost';
$engine = new Engine();
// default is true; set explicitly for clarity
$engine->set('flight.allow_method_override', true);
$hit = null;
$engine->route('GET /test', function () use (&$hit) {
$hit = 'get';
echo 'get';
});
$engine->route('DELETE /test', function () use (&$hit) {
$hit = 'delete';
echo 'delete';
});
$this->expectOutputString('delete');
$engine->start();
$this->assertSame('delete', $hit);
$this->assertTrue(Request::$allowMethodOverride);
$this->assertSame('DELETE', $engine->request()->method);
}
/**
* App code often touches request() before start() (set url, inspect headers, etc.).
* The flag must still win even when Request was constructed early under the default.
*/
public function testAllowMethodOverrideFalseRefreshesMethodIfRequestBuiltEarly(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/test';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'DELETE';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['HTTP_HOST'] = 'localhost';
$engine = new Engine();
$engine->set('flight.allow_method_override', false);
// Construct Request while static is still the default (true)
$this->assertTrue(Request::$allowMethodOverride);
$early = $engine->request();
$this->assertSame('DELETE', $early->method, 'pre-start construction still sees default override=on');
$hit = null;
$engine->route('GET /test', function () use (&$hit) {
$hit = 'get';
echo 'get';
});
$engine->route('DELETE /test', function () use (&$hit) {
$hit = 'delete';
echo 'delete';
});
$this->expectOutputString('get');
$engine->start();
$this->assertSame('get', $hit);
$this->assertFalse(Request::$allowMethodOverride);
$this->assertSame('GET', $engine->request()->method);
$this->assertSame($early, $engine->request(), 'same Request instance is refreshed, not replaced');
}
public function testAllowMethodOverrideFalseDoesNotClobberManualMethodWithoutOverrideInput(): void
{
// No X-HTTP-Method-Override / _method — manual method assignment must survive start().
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/someRoute';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['SERVER_NAME'] = 'localhost';
$_SERVER['HTTP_HOST'] = 'localhost';
$engine = new Engine();
$engine->set('flight.allow_method_override', false);
$engine->route('GET /someRoute', function () {
echo 'i ran';
}, true);
$engine->request()->method = 'HEAD';
$engine->request()->url = '/someRoute';
$this->expectOutputString('');
$engine->start();
$this->assertSame('HEAD', $engine->request()->method);
}
}

@ -31,11 +31,15 @@ class RequestTest extends TestCase
$_COOKIE = [];
$_FILES = [];
// Static flag leaks across tests; always restore the framework default.
Request::$allowMethodOverride = true;
$this->request = new Request();
}
protected function tearDown(): void
{
Request::$allowMethodOverride = true;
unset($_REQUEST);
unset($_SERVER);
}
@ -127,6 +131,49 @@ class RequestTest extends TestCase
$this->assertEquals('PUT', $request->method);
}
public function testMethodOverrideDisabledIgnoresHeader(): void
{
Request::$allowMethodOverride = false;
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'DELETE';
$request = new Request();
$this->assertSame('GET', $request->method);
$this->assertSame('GET', Request::getMethod());
}
public function testMethodOverrideDisabledIgnoresPostField(): void
{
Request::$allowMethodOverride = false;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_REQUEST['_method'] = 'PUT';
$request = new Request();
$this->assertSame('POST', $request->method);
$this->assertSame('POST', Request::getMethod());
}
public function testMethodOverrideFlagMustBeSetBeforeConstruction(): void
{
// Documents the caching behavior: flipping the static after construct
// does not rewrite the already-cached $request->method.
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'DELETE';
Request::$allowMethodOverride = true;
$request = new Request();
$this->assertSame('DELETE', $request->method);
Request::$allowMethodOverride = false;
$this->assertSame('DELETE', $request->method, 'cached method is not auto-refreshed');
$this->assertSame('GET', Request::getMethod(), 'getMethod() respects the new flag');
$request->method = Request::getMethod();
$this->assertSame('GET', $request->method);
}
public function testHttps(): void
{
$_SERVER['HTTPS'] = 'on';

Loading…
Cancel
Save