Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions src/Http/Dispatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

namespace Utopia\Http;

/**
* Per-request request dispatcher.
*
* Owns the mutable state for a single request/response cycle so the
* {@see Http} singleton carries only bootstrap configuration. This keeps
* the library safe under concurrent request handling (Swoole coroutines).
*
* One Dispatcher instance is constructed per request by {@see Http::run()}.
* @internal
*/
final class Dispatcher
{
private ?RouteMatch $match = null;

public function __construct(
private readonly Http $http,
private readonly Request $request,
private readonly Response $response,
) {}

public function matchedRoute(): ?Route
{
return $this->match?->route;
}

public function matchedRouteMatch(): ?RouteMatch
{
return $this->match;
}

public function handle(): void
{
if ($this->http->isCompressionEnabled()) {
$this->response->setAcceptEncoding($this->request->getHeader('accept-encoding', ''));
$this->response->setCompressionMinSize($this->http->getCompressionMinSize());
$this->response->setCompressionSupported($this->http->getCompressionSupported());
}

$this->http->setRequestResource('request', fn() => $this->request);
$this->http->setRequestResource('response', fn() => $this->response);

try {
foreach (Hooks::$request as $hook) {
$arguments = $this->http->getArguments($hook, [], []);
\call_user_func_array($hook->getAction(), $arguments);
}
} catch (\Exception $e) {
$this->http->setRequestResource('error', fn() => $e);

foreach (Hooks::$errors as $error) {
if (\in_array('*', $error->getGroups())) {
try {
$arguments = $this->http->getArguments($error, [], []);
\call_user_func_array($error->getAction(), $arguments);
} catch (\Throwable $e) {
throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
}
}
}
}

if ($this->http->isFileLoaded($this->request->getURI())) {
$time = (60 * 60 * 24 * 365 * 2);

$this->response
->setContentType($this->http->getFileMimeType($this->request->getURI()))
->addHeader('Cache-Control', 'public, max-age=' . $time)
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT')
->send($this->http->getFileContents($this->request->getURI()));

return;
}
Comment on lines +46 to +76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fall-through after onRequest error handling can double-send / dispatch a route.

After catching an onRequest hook error and running wildcard error handlers (which almost always write a response), control continues into static-file serving (Lines 66–76) and then into route matching + execute(). That means the matched route action and its init/shutdown hooks can still run after the request was already rejected, and ->send() can be invoked twice. Consider returning early from the catch block, or guarding the rest of handle() with if ($this->response->isSent()) { return; } — similar to the isSent() check already used at Line 160 inside execute().

🔧 Proposed fix
         } catch (\Exception $e) {
             $this->http->setRequestResource('error', fn() => $e);
 
             foreach (Hooks::$errors as $error) {
                 if (\in_array('*', $error->getGroups())) {
                     try {
                         $arguments = $this->http->getArguments($error, [], []);
                         \call_user_func_array($error->getAction(), $arguments);
                     } catch (\Throwable $e) {
                         throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
                     }
                 }
             }
+
+            return;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Dispatcher.php` around lines 46 - 76, After running onRequest hooks
and then wildcard error handlers in the catch block (Hooks::$request /
Hooks::$errors) the code continues into static file serving and route dispatch,
which can cause double responses; modify Dispatcher::handle() to abort further
processing if an error handler already sent a response—either return early from
the catch block after running error handlers or add a guard before static-file
serving and route execution using $this->response->isSent() (same check used in
execute()) so that isFileLoaded(), ->send(), and subsequent route
init/execute/shutdown are skipped when a response is already sent.


$method = $this->request->getMethod();
$this->match = Router::matchRequest($this->request);

$this->http->setRequestResource('route', fn() => $this->match?->route);
$this->http->setRequestResource('routeMatch', fn() => $this->match);

$groups = $this->match?->route->getGroups() ?? [];

if (Http::REQUEST_METHOD_HEAD === $method) {
$method = Http::REQUEST_METHOD_GET;
$this->response->disablePayload();
}
Comment on lines +66 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether Response::send honors disablePayload via an internal flag
# and whether any existing test covers HEAD-against-static-file to document
# pre-existing vs regression behavior.
rg -nP --type=php -C3 '\bdisablePayload\s*\('
rg -nP --type=php -C3 'function\s+send\s*\(' -g 'src/Http/Response*.php'
rg -nP --type=php -C3 'REQUEST_METHOD_HEAD|isFileLoaded' -g 'tests/**'

Repository: utopia-php/http

Length of output: 1850


🏁 Script executed:

sed -n '537,620p' src/Http/Response.php

Repository: utopia-php/http

Length of output: 2812


🏁 Script executed:

sed -n '640,680p' src/Http/Response.php

Repository: utopia-php/http

Length of output: 1057


🏁 Script executed:

sed -n '60,95p' src/Http/Http/Dispatcher.php

Repository: utopia-php/http

Length of output: 131


🏁 Script executed:

# Search for any test that uses HEAD method or tests static files
rg -i "head.*static|static.*head" tests/ --type=php
rg "REQUEST_METHOD_HEAD" tests/ --type=php -B2 -A2
rg "loadFiles|isFileLoaded" tests/ --type=php -B2 -A2

Repository: utopia-php/http

Length of output: 41


🏁 Script executed:

fd -type f "Dispatcher.php" | head -5

Repository: utopia-php/http

Length of output: 230


🏁 Script executed:

# Find correct path and read the file
DISP_PATH=$(fd -type f "Dispatcher.php" | head -1)
sed -n '60,95p' "$DISP_PATH"

Repository: utopia-php/http

Length of output: 274


🏁 Script executed:

# Search for tests that exercise Dispatcher with static files or HEAD
rg "Dispatcher|loadFiles" tests/ --type=php -l | head -10

Repository: utopia-php/http

Length of output: 84


🏁 Script executed:

fd "Dispatcher.php" --type f | grep -v test

Repository: utopia-php/http

Length of output: 83


🏁 Script executed:

# Once we find it, read the dispatcher code
DISP=$(fd "Dispatcher.php" --type f | grep -v test)
sed -n '60,95p' "$DISP"

Repository: utopia-php/http

Length of output: 1471


🏁 Script executed:

# Check DispatcherTest.php for HEAD or static file tests
head -100 tests/DispatcherTest.php

Repository: utopia-php/http

Length of output: 3219


🏁 Script executed:

# Search for HEAD method tests in DispatcherTest
rg -A10 -B5 "HEAD|head|loadFile" tests/DispatcherTest.php

Repository: utopia-php/http

Length of output: 739


🏁 Script executed:

# Check if Response::send has any other HEAD handling besides disablePayload
rg -C5 "REQUEST_METHOD_HEAD" src/Http/Response.php

Repository: utopia-php/http

Length of output: 41


🏁 Script executed:

# Double-check: does Response have any HEAD-specific logic in send/write methods?
rg "HEAD|head" src/Http/Response.php | grep -i "method\|header"

Repository: utopia-php/http

Length of output: 2886


HEAD requests to preloaded static files still stream the full body.

$this->response->disablePayload() is only invoked at line 88, after the static-file short-circuit at lines 66–76 has already called ->send($fileContents). HEAD requests whose URI matches a file loaded via loadFiles() will emit the body instead of headers-only, violating HTTP HEAD semantics. The existing test testHeadRequestResolvesToGetRouteWithPayloadDisabled covers HEAD for routes but not static files.

Normalize the method before the file-serving branch:

🔧 Proposed fix
+        $method = $this->request->getMethod();
+        if (Http::REQUEST_METHOD_HEAD === $method) {
+            $this->response->disablePayload();
+        }
+
         if ($this->http->isFileLoaded($this->request->getURI())) {
             $time = (60 * 60 * 24 * 365 * 2);
 
             $this->response
                 ->setContentType($this->http->getFileMimeType($this->request->getURI()))
                 ->addHeader('Cache-Control', 'public, max-age=' . $time)
                 ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + $time) . ' GMT')
                 ->send($this->http->getFileContents($this->request->getURI()));
 
             return;
         }
 
-        $method = $this->request->getMethod();
         $this->match = Router::matchRequest($this->request);
 
         $this->http->setRequestResource('route', fn() => $this->match?->route);
         $this->http->setRequestResource('routeMatch', fn() => $this->match);
 
         $groups = $this->match?->route->getGroups() ?? [];
 
         if (Http::REQUEST_METHOD_HEAD === $method) {
             $method = Http::REQUEST_METHOD_GET;
-            $this->response->disablePayload();
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/Dispatcher.php` around lines 66 - 89, The Dispatcher currently
checks for static files before normalizing HEAD requests, so HEAD requests to
preloaded files still send bodies; to fix, obtain and normalize the request
method early (call $this->request->getMethod() and if it's
Http::REQUEST_METHOD_HEAD set $method = Http::REQUEST_METHOD_GET and call
$this->response->disablePayload()) before the
isFileLoaded($this->request->getURI()) branch so that when isFileLoaded(...)
triggers the subsequent ->send(...) it will have payloads disabled for HEAD;
update references to $method and keep existing use of isFileLoaded,
getFileContents, send, matchRequest, setRequestResource, etc., unchanged except
for moving the normalization logic earlier in Dispatcher.


if (Http::REQUEST_METHOD_OPTIONS === $method) {
try {
foreach ($groups as $group) {
foreach (Hooks::$options as $option) {
if (\in_array($group, $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->http->getArguments($option, [], $this->request->getParams()));
}
}
}

foreach (Hooks::$options as $option) {
if (\in_array('*', $option->getGroups())) {
\call_user_func_array($option->getAction(), $this->http->getArguments($option, [], $this->request->getParams()));
}
}
} catch (\Throwable $e) {
foreach (Hooks::$errors as $error) {
if (\in_array('*', $error->getGroups())) {
$this->http->setRequestResource('error', fn() => $e);
\call_user_func_array($error->getAction(), $this->http->getArguments($error, [], $this->request->getParams()));
}
}
}

return;
}

if ($this->match !== null) {
$this->execute($this->match);

return;
}

foreach (Hooks::$errors as $error) {
if (\in_array('*', $error->getGroups())) {
$this->http->setRequestResource('error', fn() => new Exception('Not Found', 404));
\call_user_func_array($error->getAction(), $this->http->getArguments($error, [], $this->request->getParams()));
}
}
}

public function execute(RouteMatch $match): void
{
$route = $match->route;
$groups = $route->getGroups();
$pathValues = $route->getPathValues($this->request, $match->preparedPath);

// Request params are re-read at each call site: init/request hooks
// may mutate the request (e.g. applying filters), and later hooks +
// the route action must see the updated view. Hoisting this into a
// local would cache stale params across the lifecycle.

try {
if ($route->getHook()) {
foreach (Hooks::$init as $hook) {
if (\in_array('*', $hook->getGroups())) {
\call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams()));
}
}
}

foreach ($groups as $group) {
foreach (Hooks::$init as $hook) {
if (\in_array($group, $hook->getGroups())) {
\call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams()));
}
}
}

if (!$this->response->isSent()) {
\call_user_func_array($route->getAction(), $this->http->getArguments($route, $pathValues, $this->request->getParams()));
}

foreach ($groups as $group) {
foreach (Hooks::$shutdown as $hook) {
if (\in_array($group, $hook->getGroups())) {
\call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams()));
}
}
}

if ($route->getHook()) {
foreach (Hooks::$shutdown as $hook) {
if (\in_array('*', $hook->getGroups())) {
\call_user_func_array($hook->getAction(), $this->http->getArguments($hook, $pathValues, $this->request->getParams()));
}
}
}
} catch (\Throwable $e) {
$this->http->setRequestResource('error', fn() => $e);

foreach ($groups as $group) {
foreach (Hooks::$errors as $error) {
if (\in_array($group, $error->getGroups())) {
try {
\call_user_func_array($error->getAction(), $this->http->getArguments($error, $pathValues, $this->request->getParams()));
} catch (\Throwable $e) {
throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
}
}
}
}

foreach (Hooks::$errors as $error) {
if (\in_array('*', $error->getGroups())) {
try {
\call_user_func_array($error->getAction(), $this->http->getArguments($error, $pathValues, $this->request->getParams()));
} catch (\Throwable $e) {
throw new Exception('Error handler had an error: ' . $e->getMessage(), 500, $e);
}
}
}
}
}
}
125 changes: 125 additions & 0 deletions src/Http/Hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

declare(strict_types=1);

namespace Utopia\Http;

use Utopia\Servers\Hook;

/**
* Process-global hook registry.
*
* Owns the lifecycle-hook arrays (init/shutdown/options/error/start/request)
* that used to live on {@see Http} as protected statics. Keeping them here
* means {@see Dispatcher} can read the registries through a dedicated
* primitive instead of forcing {@see Http} to expose six `getXxxHooks()`
* accessors purely for internal consumption.
*
* Hooks are populated at bootstrap and must not be mutated after the
* server starts accepting requests; registration APIs are public static
* methods, reads are public static arrays.
*/
final class Hooks
{
/** @var Hook[] */
public static array $init = [];

/** @var Hook[] */
public static array $shutdown = [];

/** @var Hook[] */
public static array $options = [];

/** @var Hook[] */
public static array $errors = [];

/** @var Hook[] */
public static array $start = [];

/** @var Hook[] */
public static array $request = [];

/**
* Register a callback that runs before the matched route action.
*/
public static function init(): Hook
{
$hook = new Hook();
$hook->groups(['*']);
self::$init[] = $hook;

return $hook;
}

/**
* Register a callback that runs after the matched route action.
*/
public static function shutdown(): Hook
{
$hook = new Hook();
$hook->groups(['*']);
self::$shutdown[] = $hook;

return $hook;
}

/**
* Register a callback for OPTIONS method requests.
*/
public static function options(): Hook
{
$hook = new Hook();
$hook->groups(['*']);
self::$options[] = $hook;

return $hook;
}

/**
* Register an error callback.
*/
public static function error(): Hook
{
$hook = new Hook();
$hook->groups(['*']);
self::$errors[] = $hook;

return $hook;
}

/**
* Register a callback that runs once when the server starts.
*/
public static function onStart(): Hook
{
$hook = new Hook();
self::$start[] = $hook;

return $hook;
}

/**
* Register a callback that runs at the top of every request, before
* route matching.
*/
public static function onRequest(): Hook
{
$hook = new Hook();
self::$request[] = $hook;

return $hook;
}

/**
* Clear every registered hook. Intended for test isolation.
*/
public static function reset(): void
{
self::$init = [];
self::$shutdown = [];
self::$options = [];
self::$errors = [];
self::$start = [];
self::$request = [];
}
}
Loading
Loading